feat: dashboard (#860)

* feat: plugins

* feat: dashboard panel and plugin publish procedure

* chore: rename dashboard db name

* feat: base query add cell format params

* feat: dashboard and plugin render

* feat: dashboard permission controll

* chore: remove chart page

* feat: add isExpand status

* feat: auth plugin render

* feat: chart plugin

* chore: add plugin chart scripts

* chore: remove dist

* feat: plugin docker build and chart plugin init

* chore: plugin chart build

* chore: plugin chart lint

* fix: base query e2e

* fix: markdown preview theme

* fix: plugin e2e

* fix: first admin user

* fix: insert env in nextjs-app/.env

* fix: e2e error

* fix: plugin rows

* fix: plugin and dashboard service spec

* fix: init official plugin lock attachments database table

* fix: test error

* fix: init plugin conflict on e2e

* fix: init plugin conflict on e2e

* fix: init plugin conflict on e2e

* fix: init plugin conflict on e2e

* chore: better message

* fix: init plugin conflict on e2e

* chore: remove lock
This commit is contained in:
Boris 2024-09-18 12:15:23 +08:00 committed by GitHub
parent a184b5a47b
commit a3171aedfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
241 changed files with 10091 additions and 1111 deletions

View File

@ -159,6 +159,7 @@
"fs-extra": "11.2.0",
"handlebars": "4.7.8",
"helmet": "7.1.0",
"http-proxy-middleware": "3.0.2",
"ioredis": "5.4.1",
"is-port-reachable": "3.1.0",
"joi": "17.12.2",

View File

@ -7,6 +7,7 @@ import { AuthModule } from './features/auth/auth.module';
import { BaseModule } from './features/base/base.module';
import { ChatModule } from './features/chat/chat.module';
import { CollaboratorModule } from './features/collaborator/collaborator.module';
import { DashboardModule } from './features/dashboard/dashboard.module';
import { ExportOpenApiModule } from './features/export/open-api/export-open-api.module';
import { FieldOpenApiModule } from './features/field/open-api/field-open-api.module';
import { HealthModule } from './features/health/health.module';
@ -16,6 +17,7 @@ import { NextModule } from './features/next/next.module';
import { NotificationModule } from './features/notification/notification.module';
import { OAuthModule } from './features/oauth/oauth.module';
import { PinModule } from './features/pin/pin.module';
import { PluginModule } from './features/plugin/plugin.module';
import { SelectionModule } from './features/selection/selection.module';
import { SettingModule } from './features/setting/setting.module';
import { ShareModule } from './features/share/share.module';
@ -55,6 +57,8 @@ export const appModules = {
SettingModule,
OAuthModule,
TrashModule,
PluginModule,
DashboardModule,
],
providers: [InitBootstrapProvider],
};

View File

@ -22,6 +22,7 @@ export interface ICacheStore {
// userId:tableId:windowId
[key: `operations:undo:${string}:${string}:${string}`]: IUndoRedoOperation[];
[key: `operations:redo:${string}:${string}:${string}`]: IUndoRedoOperation[];
[key: `plugin:auth-code:${string}`]: IPluginAuthStore;
}
export interface IAttachmentSignatureCache {
@ -242,3 +243,7 @@ export type IUndoRedoOperation =
| ICreateViewOperation
| IDeleteViewOperation
| IUpdateViewOperation;
export interface IPluginAuthStore {
baseId: string;
pluginId: string;
}

View File

@ -13,6 +13,7 @@ export const baseConfig = registerAs('base', () => ({
defaultMaxBaseDBConnections: Number(process.env.DEFAULT_MAX_BASE_DB_CONNECTIONS ?? 20),
templateSpaceId: process.env.TEMPLATE_SPACE_ID,
recordHistoryDisabled: process.env.RECORD_HISTORY_DISABLED === 'true',
pluginServerPort: process.env.PLUGIN_SERVER_PORT || '3002',
}));
export const BaseConfig = () => Inject(baseConfig.KEY);

View File

@ -45,15 +45,19 @@ export class AccessTokenService {
async validate(splitAccessTokenObj: { accessTokenId: string; sign: string }) {
const { accessTokenId, sign } = splitAccessTokenObj;
const accessTokenEntity = await this.prismaService.accessToken.findUniqueOrThrow({
where: { id: accessTokenId },
select: {
userId: true,
id: true,
sign: true,
expiredTime: true,
},
});
const accessTokenEntity = await this.prismaService.accessToken
.findUniqueOrThrow({
where: { id: accessTokenId },
select: {
userId: true,
id: true,
sign: true,
expiredTime: true,
},
})
.catch(() => {
throw new UnauthorizedException('token not found');
});
if (sign !== accessTokenEntity.sign) {
throw new UnauthorizedException('sign error');
}

View File

@ -66,6 +66,7 @@ export class AttachmentsController {
headers['Content-Disposition'] = responseContentDisposition;
}
}
headers['Cross-Origin-Resource-Policy'] = 'unsafe-none';
res.set(headers);
return new StreamableFile(fileStream);
}

View File

@ -13,6 +13,7 @@ export default abstract class StorageAdapter {
case UploadType.Avatar:
case UploadType.OAuth:
case UploadType.Form:
case UploadType.Plugin:
return storageConfig().publicBucket;
default:
throw new BadRequestException('Invalid upload type');
@ -31,6 +32,8 @@ export default abstract class StorageAdapter {
return 'oauth';
case UploadType.Import:
return 'import';
case UploadType.Plugin:
return 'plugin';
default:
throw new BadRequestException('Invalid upload type');
}

View File

@ -149,11 +149,13 @@ export class LocalStorage implements StorageAdapter {
});
}
async save(filePath: string, rename: string) {
async save(filePath: string, rename: string, isDelete: boolean = true) {
const distPath = resolve(this.storageDir);
const newFilePath = resolve(distPath, rename);
await fse.copy(filePath, newFilePath);
await fse.remove(filePath);
if (isDelete) {
await fse.remove(filePath);
}
return join(this.path, rename);
}
@ -239,7 +241,7 @@ export class LocalStorage implements StorageAdapter {
_metadata: Record<string, unknown>
) {
const hash = await FileUtils.getHash(filePath);
await this.save(filePath, join(bucket, path));
await this.save(filePath, join(bucket, path), false);
return {
hash,
path,

View File

@ -81,7 +81,7 @@ export class PermissionService {
select: { scopes: true, spaceIds: true, baseIds: true, clientId: true, userId: true },
});
const scopes = JSON.parse(stringifyScopes) as Action[];
if (clientId) {
if (clientId && clientId.startsWith(IdPrefix.OAuthClient)) {
const { spaceIds: spaceIdsByOAuth, baseIds: baseIdsByOAuth } =
await this.getOAuthAccessBy(userId);
return {

View File

@ -30,7 +30,7 @@ export class AccessTokenStrategy extends PassportStrategy(PassportAccessTokenStr
const user = await this.userService.getUserById(userId);
if (!user) {
throw new UnauthorizedException();
throw new UnauthorizedException('User not found');
}
if (user.deactivatedTime) {
throw new UnauthorizedException('Your account has been deactivated by the administrator');

View File

@ -1,4 +1,6 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { IAttachmentCellValue } from '@teable/core';
import { CellFormat, FieldType } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { BaseQueryColumnType, BaseQueryJoinType } from '@teable/openapi';
import type { IBaseQueryJoin, IBaseQuery, IBaseQueryVo, IBaseQueryColumn } from '@teable/openapi';
@ -14,6 +16,7 @@ import {
createFieldInstanceByVo,
type IFieldInstance,
} from '../../field/model/factory';
import { RecordService } from '../../record/record.service';
import { QueryAggregation } from './parse/aggregation';
import { QueryFilter } from './parse/filter';
import { QueryGroup } from './parse/group';
@ -31,7 +34,8 @@ export class BaseQueryService {
private readonly fieldService: FieldService,
private readonly prismaService: PrismaService,
private readonly cls: ClsService<IClsStore>
private readonly cls: ClsService<IClsStore>,
private readonly recordService: RecordService
) {}
private convertFieldMapToColumn(fieldMap: Record<string, IFieldInstance>): IBaseQueryColumn[] {
@ -48,23 +52,56 @@ export class BaseQueryService {
});
}
private handleBigIntRows(rows: { [key in string]: unknown }[]) {
return rows.map((row) => {
return Object.entries(row).reduce(
(acc, [key, value]) => {
// eslint-disable-next-line sonarjs/cognitive-complexity
private async dbRows2Rows(
rows: Record<string, unknown>[],
columns: IBaseQueryColumn[],
cellFormat: CellFormat
) {
const resRows: Record<string, unknown>[] = [];
for (const row of rows) {
const resRow: Record<string, unknown> = {};
for (const field of columns) {
if (!field.fieldSource) {
const value = row[field.column];
resRow[field.column] = row[field.column];
// handle bigint
if (typeof value === 'bigint') {
acc[key] = Number(value);
resRow[field.column] = Number(value);
} else {
acc[key] = value;
resRow[field.column] = value;
}
return acc;
},
{} as { [key in string]: unknown }
);
});
continue;
}
const dbCellValue = row[field.column];
const fieldInstance = createFieldInstanceByVo(field.fieldSource);
const cellValue = fieldInstance.convertDBValue2CellValue(dbCellValue);
// number no need to convert string
if (typeof cellValue === 'number') {
resRow[field.column] = cellValue;
continue;
}
if (cellValue != null) {
resRow[field.column] =
cellFormat === CellFormat.Text ? fieldInstance.cellValue2String(cellValue) : cellValue;
}
if (fieldInstance.type === FieldType.Attachment) {
resRow[field.column] = await this.recordService.getAttachmentPresignedCellValue(
cellValue as IAttachmentCellValue
);
}
}
resRows.push(resRow);
}
return resRows;
}
async baseQuery(baseId: string, baseQuery: IBaseQuery): Promise<IBaseQueryVo> {
async baseQuery(
baseId: string,
baseQuery: IBaseQuery,
cellFormat: CellFormat = CellFormat.Json
): Promise<IBaseQueryVo> {
const { queryBuilder, fieldMap } = await this.parseBaseQuery(baseId, baseQuery, 0);
const query = queryBuilder.toQuery();
this.logger.log('baseQuery SQL: ', query);
@ -74,10 +111,11 @@ export class BaseQueryService {
this.logger.error(e);
throw new BadRequestException(`Query failed: ${query}, ${e.message}`);
});
const columns = this.convertFieldMapToColumn(fieldMap);
return {
rows: this.handleBigIntRows(rows),
columns: this.convertFieldMapToColumn(fieldMap),
rows: await this.dbRows2Rows(rows, columns, cellFormat),
columns,
};
}

View File

@ -174,11 +174,12 @@ export class BaseController {
}
@Get(':baseId/query')
@Permissions('base|query_data')
async sqlQuery(
@Param('baseId') baseId: string,
@Query(new ZodValidationPipe(baseQuerySchemaRo)) query: IBaseQuerySchemaRo
) {
return this.baseQueryService.baseQuery(baseId, query.query);
return this.baseQueryService.baseQuery(baseId, query.query, query.cellFormat);
}
@Permissions('base|invite_link')

View File

@ -3,6 +3,7 @@ import { DbProvider } from '../../db-provider/db.provider';
import { CollaboratorModule } from '../collaborator/collaborator.module';
import { FieldModule } from '../field/field.module';
import { InvitationModule } from '../invitation/invitation.module';
import { RecordModule } from '../record/record.module';
import { TableOpenApiModule } from '../table/open-api/table-open-api.module';
import { TableModule } from '../table/table.module';
import { BaseDuplicateService } from './base-duplicate.service';
@ -13,7 +14,14 @@ import { DbConnectionService } from './db-connection.service';
@Module({
controllers: [BaseController],
imports: [CollaboratorModule, FieldModule, TableModule, InvitationModule, TableOpenApiModule],
imports: [
CollaboratorModule,
FieldModule,
TableModule,
InvitationModule,
TableOpenApiModule,
RecordModule,
],
providers: [DbProvider, BaseService, DbConnectionService, BaseDuplicateService, BaseQueryService],
exports: [BaseService, DbConnectionService, BaseDuplicateService],
})

View File

@ -0,0 +1,19 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { DashboardController } from './dashboard.controller';
describe('DashboardController', () => {
let controller: DashboardController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DashboardController],
}).compile();
controller = module.get<DashboardController>(DashboardController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,135 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import {
createDashboardRoSchema,
dashboardInstallPluginRoSchema,
ICreateDashboardRo,
IRenameDashboardRo,
IUpdateLayoutDashboardRo,
renameDashboardRoSchema,
updateLayoutDashboardRoSchema,
IDashboardInstallPluginRo,
dashboardPluginUpdateStorageRoSchema,
IDashboardPluginUpdateStorageRo,
} from '@teable/openapi';
import type {
ICreateDashboardVo,
IGetDashboardVo,
IRenameDashboardVo,
IUpdateLayoutDashboardVo,
IGetDashboardListVo,
IDashboardInstallPluginVo,
IDashboardPluginUpdateStorageVo,
IGetDashboardInstallPluginVo,
} from '@teable/openapi';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { DashboardService } from './dashboard.service';
@Controller('api/base/:baseId/dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get()
@Permissions('base|read')
getDashboard(@Param('baseId') baseId: string): Promise<IGetDashboardListVo> {
return this.dashboardService.getDashboard(baseId);
}
@Get(':id')
@Permissions('base|read')
getDashboardById(
@Param('baseId') baseId: string,
@Param('id') id: string
): Promise<IGetDashboardVo> {
return this.dashboardService.getDashboardById(baseId, id);
}
@Post()
@Permissions('base|update')
createDashboard(
@Param('baseId') baseId: string,
@Body(new ZodValidationPipe(createDashboardRoSchema)) ro: ICreateDashboardRo
): Promise<ICreateDashboardVo> {
return this.dashboardService.createDashboard(baseId, ro);
}
@Patch(':id/rename')
@Permissions('base|update')
updateDashboard(
@Param('baseId') baseId: string,
@Param('id') id: string,
@Body(new ZodValidationPipe(renameDashboardRoSchema)) ro: IRenameDashboardRo
): Promise<IRenameDashboardVo> {
return this.dashboardService.renameDashboard(baseId, id, ro.name);
}
@Patch(':id/layout')
@Permissions('base|update')
updateLayout(
@Param('baseId') baseId: string,
@Param('id') id: string,
@Body(new ZodValidationPipe(updateLayoutDashboardRoSchema)) ro: IUpdateLayoutDashboardRo
): Promise<IUpdateLayoutDashboardVo> {
return this.dashboardService.updateLayout(baseId, id, ro.layout);
}
@Delete(':id')
@Permissions('base|update')
deleteDashboard(@Param('baseId') baseId: string, @Param('id') id: string): Promise<void> {
return this.dashboardService.deleteDashboard(baseId, id);
}
@Post(':id/plugin')
@Permissions('base|update')
installPlugin(
@Param('baseId') baseId: string,
@Param('id') id: string,
@Body(new ZodValidationPipe(dashboardInstallPluginRoSchema)) ro: IDashboardInstallPluginRo
): Promise<IDashboardInstallPluginVo> {
return this.dashboardService.installPlugin(baseId, id, ro);
}
@Delete(':id/plugin/:pluginInstallId')
@Permissions('base|update')
removePlugin(
@Param('baseId') baseId: string,
@Param('id') id: string,
@Param('pluginInstallId') pluginInstallId: string
): Promise<void> {
return this.dashboardService.removePlugin(baseId, id, pluginInstallId);
}
@Patch(':id/plugin/:pluginInstallId/rename')
@Permissions('base|update')
renamePlugin(
@Param('baseId') baseId: string,
@Param('id') id: string,
@Param('pluginInstallId') pluginInstallId: string,
@Body(new ZodValidationPipe(renameDashboardRoSchema)) ro: IRenameDashboardRo
): Promise<IRenameDashboardVo> {
return this.dashboardService.renamePlugin(baseId, id, pluginInstallId, ro.name);
}
@Patch(':id/plugin/:pluginInstallId/update-storage')
@Permissions('base|update')
updatePluginStorage(
@Param('baseId') baseId: string,
@Param('id') id: string,
@Param('pluginInstallId') pluginInstallId: string,
@Body(new ZodValidationPipe(dashboardPluginUpdateStorageRoSchema))
ro: IDashboardPluginUpdateStorageRo
): Promise<IDashboardPluginUpdateStorageVo> {
return this.dashboardService.updatePluginStorage(baseId, id, pluginInstallId, ro.storage);
}
@Get(':id/plugin/:pluginInstallId')
@Permissions('base|read')
getPluginInstall(
@Param('baseId') baseId: string,
@Param('id') id: string,
@Param('pluginInstallId') pluginInstallId: string
): Promise<IGetDashboardInstallPluginVo> {
return this.dashboardService.getPluginInstall(baseId, id, pluginInstallId);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
providers: [DashboardService],
controllers: [DashboardController],
})
export class DashboardModule {}

View File

@ -0,0 +1,21 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { GlobalModule } from '../../global/global.module';
import { DashboardModule } from './dashboard.module';
import { DashboardService } from './dashboard.service';
describe('DashboardService', () => {
let service: DashboardService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [GlobalModule, DashboardModule],
}).compile();
service = module.get<DashboardService>(DashboardService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,363 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Injectable, NotFoundException } from '@nestjs/common';
import { generateDashboardId, generatePluginInstallId } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { PluginPosition, PluginStatus } from '@teable/openapi';
import type {
ICreateDashboardRo,
IDashboardInstallPluginRo,
IGetDashboardInstallPluginVo,
IGetDashboardListVo,
IGetDashboardVo,
IUpdateLayoutDashboardRo,
} from '@teable/openapi';
import type { IDashboardLayout, IDashboardPluginItem } from '@teable/openapi/src/dashboard/types';
import { ClsService } from 'nestjs-cls';
import type { IClsStore } from '../../types/cls';
@Injectable()
export class DashboardService {
constructor(
private readonly prismaService: PrismaService,
private readonly cls: ClsService<IClsStore>
) {}
async getDashboard(baseId: string): Promise<IGetDashboardListVo> {
return this.prismaService.dashboard.findMany({
where: {
baseId,
},
select: {
id: true,
name: true,
},
});
}
async getDashboardById(baseId: string, id: string): Promise<IGetDashboardVo> {
const dashboard = await this.prismaService.dashboard
.findFirstOrThrow({
where: {
id,
baseId,
},
select: {
id: true,
name: true,
layout: true,
},
})
.catch(() => {
throw new NotFoundException('Dashboard not found');
});
const plugins = await this.prismaService.pluginInstall.findMany({
where: {
positionId: dashboard.id,
position: PluginPosition.Dashboard,
},
select: {
id: true,
name: true,
pluginId: true,
plugin: {
select: {
url: true,
},
},
},
});
return {
...dashboard,
layout: dashboard.layout ? JSON.parse(dashboard.layout) : undefined,
pluginMap: plugins.reduce(
(acc, plugin) => {
acc[plugin.id] = {
id: plugin.pluginId,
pluginInstallId: plugin.id,
name: plugin.name,
url: plugin.plugin.url ?? undefined,
};
return acc;
},
{} as Record<string, IDashboardPluginItem>
),
};
}
async createDashboard(baseId: string, dashboard: ICreateDashboardRo) {
const userId = this.cls.get('user.id');
return this.prismaService.dashboard.create({
data: {
id: generateDashboardId(),
baseId,
name: dashboard.name,
createdBy: userId,
},
select: {
id: true,
name: true,
},
});
}
async renameDashboard(baseId: string, id: string, name: string) {
return this.prismaService.dashboard
.update({
where: {
baseId,
id,
},
data: {
name,
},
select: {
id: true,
name: true,
},
})
.catch(() => {
throw new NotFoundException('Dashboard not found');
});
}
async updateLayout(baseId: string, id: string, layout: IUpdateLayoutDashboardRo['layout']) {
const ro = await this.prismaService.dashboard
.update({
where: {
baseId,
id,
},
data: {
layout: JSON.stringify(layout),
},
select: {
id: true,
name: true,
layout: true,
},
})
.catch(() => {
throw new NotFoundException('Dashboard not found');
});
return {
...ro,
layout: ro.layout ? JSON.parse(ro.layout) : undefined,
};
}
async deleteDashboard(baseId: string, id: string) {
await this.prismaService.dashboard
.delete({
where: {
baseId,
id,
},
})
.catch(() => {
throw new NotFoundException('Dashboard not found');
});
}
private async validatePluginPublished(_baseId: string, pluginId: string) {
return this.prismaService.plugin
.findFirstOrThrow({
where: {
id: pluginId,
status: PluginStatus.Published,
},
})
.catch(() => {
throw new NotFoundException('Plugin not found');
});
}
async installPlugin(baseId: string, id: string, ro: IDashboardInstallPluginRo) {
const userId = this.cls.get('user.id');
await this.validatePluginPublished(baseId, ro.pluginId);
return this.prismaService.$tx(async () => {
const newInstallPlugin = await this.prismaService.txClient().pluginInstall.create({
data: {
id: generatePluginInstallId(),
baseId,
positionId: id,
position: PluginPosition.Dashboard,
name: ro.name,
pluginId: ro.pluginId,
createdBy: userId,
},
select: {
id: true,
name: true,
pluginId: true,
},
});
const dashboard = await this.prismaService.txClient().dashboard.findFirstOrThrow({
where: {
id,
baseId,
},
select: {
layout: true,
},
});
const layout = dashboard.layout ? (JSON.parse(dashboard.layout) as IDashboardLayout) : [];
layout.push({
pluginInstallId: newInstallPlugin.id,
x: (layout.length * 2) % 12,
y: Number.MAX_SAFE_INTEGER, // puts it at the bottom
w: 2,
h: 2,
});
await this.prismaService.txClient().dashboard.update({
where: {
id,
},
data: {
layout: JSON.stringify(layout),
},
});
return {
id,
pluginId: newInstallPlugin.pluginId,
pluginInstallId: newInstallPlugin.id,
name: ro.name,
};
});
}
private async validateDashboard(baseId: string, dashboardId: string) {
await this.prismaService
.txClient()
.dashboard.findFirstOrThrow({
where: {
baseId,
id: dashboardId,
},
})
.catch(() => {
throw new NotFoundException('Dashboard not found');
});
}
async removePlugin(baseId: string, dashboardId: string, pluginInstallId: string) {
return this.prismaService.$tx(async () => {
await this.prismaService
.txClient()
.pluginInstall.delete({
where: {
id: pluginInstallId,
baseId,
positionId: dashboardId,
plugin: {
status: PluginStatus.Published,
},
},
})
.catch(() => {
throw new NotFoundException('Plugin not found');
});
const dashboard = await this.prismaService.txClient().dashboard.findFirstOrThrow({
where: {
id: dashboardId,
baseId,
},
select: {
layout: true,
},
});
const layout = dashboard.layout ? (JSON.parse(dashboard.layout) as IDashboardLayout) : [];
const index = layout.findIndex((item) => item.pluginInstallId === pluginInstallId);
if (index !== -1) {
layout.splice(index, 1);
await this.prismaService.txClient().dashboard.update({
where: {
id: dashboardId,
},
data: {
layout: JSON.stringify(layout),
},
});
}
});
}
private async validateAndGetPluginInstall(pluginInstallId: string) {
return this.prismaService
.txClient()
.pluginInstall.findFirstOrThrow({
where: {
id: pluginInstallId,
plugin: {
status: PluginStatus.Published,
},
},
})
.catch(() => {
throw new NotFoundException('Plugin not found');
});
}
async renamePlugin(baseId: string, dashboardId: string, pluginInstallId: string, name: string) {
return this.prismaService.$tx(async () => {
await this.validateDashboard(baseId, dashboardId);
const plugin = await this.validateAndGetPluginInstall(pluginInstallId);
await this.prismaService.txClient().pluginInstall.update({
where: {
id: pluginInstallId,
},
data: {
name,
},
});
return {
id: plugin.pluginId,
pluginInstallId,
name,
};
});
}
async updatePluginStorage(
baseId: string,
dashboardId: string,
pluginInstallId: string,
storage?: Record<string, unknown>
) {
return this.prismaService.$tx(async () => {
await this.validateDashboard(baseId, dashboardId);
await this.validateAndGetPluginInstall(pluginInstallId);
const res = await this.prismaService.txClient().pluginInstall.update({
where: {
id: pluginInstallId,
},
data: {
storage: storage ? JSON.stringify(storage) : null,
},
});
return {
baseId,
dashboardId,
pluginInstallId: res.id,
storage: res.storage ? JSON.parse(res.storage) : undefined,
};
});
}
async getPluginInstall(
baseId: string,
dashboardId: string,
pluginInstallId: string
): Promise<IGetDashboardInstallPluginVo> {
await this.validateDashboard(baseId, dashboardId);
const plugin = await this.validateAndGetPluginInstall(pluginInstallId);
return {
name: plugin.name,
baseId: plugin.baseId,
pluginId: plugin.pluginId,
pluginInstallId: plugin.id,
storage: plugin.storage ? JSON.parse(plugin.storage) : undefined,
};
}
}

View File

@ -31,7 +31,6 @@ export class NextController {
'oauth/?*',
'developer/?*',
'public/?*',
'plugin/?*',
])
public async home(@Req() req: express.Request, @Res() res: express.Response) {
await this.nextService.server.getRequestHandler()(req, res);

View File

@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { NextController } from './next.controller';
import { NextService } from './next.service';
import { NextPluginModule } from './plugin/plugin.module';
@Module({
imports: [NextPluginModule],
providers: [NextService],
controllers: [NextController],
})

View File

@ -0,0 +1,21 @@
// proxy.middleware.ts
import type { NestMiddleware } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { RequestHandler } from 'http-proxy-middleware';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { BaseConfig, IBaseConfig } from '../../../configs/base.config';
export class PluginProxyMiddleware implements NestMiddleware {
private proxy: RequestHandler;
constructor(@BaseConfig() private readonly baseConfig: IBaseConfig) {
this.proxy = createProxyMiddleware({
target: `http://localhost:${baseConfig.pluginServerPort}`,
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async use(req: Request, res: Response, next: () => void): Promise<any> {
this.proxy(req, res, next);
}
}

View File

@ -0,0 +1,16 @@
import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
import { Module, RequestMethod } from '@nestjs/common';
import { PluginProxyMiddleware } from './plugin-proxy.middleware';
@Module({
providers: [],
imports: [],
})
export class PluginProxyModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
configure(consumer: MiddlewareConsumer): any {
consumer.apply(PluginProxyMiddleware).forRoutes({
method: RequestMethod.ALL,
path: 'plugin/?*',
});
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PluginProxyModule } from './plugin-proxy.module';
@Module({
imports: [PluginProxyModule],
providers: [],
controllers: [],
})
export class NextPluginModule {}

View File

@ -0,0 +1,32 @@
export const chartConfig = {
id: 'plgRuucVT5FikQSIyC9',
name: 'Chart',
description: 'Visualize your records on a bar, line, pie',
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. Make your chart pop by choosing from a set of colors, or color-code the chart to match your records' associated single select fields.
When you need to drill down into your records, clicking on any bar or point on your chart will bring up the associated record or records.
Learn more](https://teable.io)",
`,
helpUrl: 'https://teable.io',
positions: ['dashboard'],
i18n: {
zh: {
name: '图表',
helpUrl: 'https://teable.cn',
description: '通过柱状图、折线图、饼图可视化您的记录',
detailDesc:
'如果您想通过色彩丰富的方式从大局上了解表格,试试图表应用。\n\n图表应用汇总表格记录并将其转换为交互式的柱状图、折线图、饼图。通过选择一组颜色让您的图表更引人注目或根据记录的单选字段为图表添加颜色编码。\n\n当您需要深入了解记录时点击图表上的任何柱状或点状部分即可显示相关记录或记录详情。\n\n[了解更多](https://teable.cn)',
},
},
logoPath: 'static/plugin/chart.png',
};

View File

@ -0,0 +1,165 @@
import { join, resolve } from 'path';
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generatePluginUserId, getPluginEmail } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { PluginStatus, UploadType } from '@teable/openapi';
import { createReadStream } from 'fs-extra';
import { Knex } from 'knex';
import { InjectModel } from 'nest-knexjs';
import sharp from 'sharp';
import { BaseConfig, IBaseConfig } from '../../../configs/base.config';
import StorageAdapter from '../../attachments/plugins/adapter';
import { InjectStorageAdapter } from '../../attachments/plugins/storage';
import { UserService } from '../../user/user.service';
import { generateSecret } from '../utils';
import { chartConfig } from './config/chart';
@Injectable()
export class OfficialPluginInitService implements OnModuleInit {
private logger = new Logger(OfficialPluginInitService.name);
constructor(
private readonly prismaService: PrismaService,
private readonly userService: UserService,
private readonly configService: ConfigService,
@InjectStorageAdapter() readonly storageAdapter: StorageAdapter,
@BaseConfig() private readonly baseConfig: IBaseConfig,
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
) {}
// init official plugins
async onModuleInit() {
const officialPlugins = [
{
...chartConfig,
secret: this.configService.get<string>('PLUGIN_CHART_SECRET') || this.baseConfig.secretKey,
url: `${this.baseConfig.publicOrigin}/plugin/chart`,
},
];
try {
await this.prismaService.$tx(async () => {
for (const plugin of officialPlugins) {
this.logger.log(`Creating official plugin: ${plugin.name}`);
await this.createOfficialPlugin(plugin);
}
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (error.code !== 'P2002') {
throw error;
}
}
}
async uploadLogo(id: string, filePath: string) {
const fileStream = createReadStream(resolve(process.cwd(), filePath));
const metaReader = sharp();
const sharpReader = fileStream.pipe(metaReader);
const { width, height, format = 'png', size = 0 } = await sharpReader.metadata();
const path = join(StorageAdapter.getDir(UploadType.Plugin), id);
const bucket = StorageAdapter.getBucket(UploadType.Plugin);
const mimetype = `image/${format}`;
const { hash } = await this.storageAdapter.uploadFileWidthPath(bucket, path, filePath, {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': mimetype,
});
await this.prismaService.txClient().attachments.upsert({
create: {
token: id,
path,
size,
width,
height,
hash,
mimetype,
createdBy: 'system',
},
update: {
size,
width,
height,
hash,
mimetype,
lastModifiedBy: 'system',
},
where: {
token: id,
deletedTime: null,
},
});
return `/${path}/${id}`;
}
async createOfficialPlugin(pluginConfig: typeof chartConfig & { secret: string; url: string }) {
const {
id: pluginId,
name,
description,
detailDesc,
logoPath,
i18n,
positions,
helpUrl,
secret,
url,
} = pluginConfig;
const rows = await this.prismaService.txClient().plugin.count({ where: { id: pluginId } });
if (rows > 0) {
const { hashedSecret, maskedSecret } = await generateSecret(secret);
return this.prismaService.txClient().plugin.update({
where: {
id: pluginId,
},
data: {
secret: hashedSecret,
maskedSecret,
},
});
}
// upload logo
const logo = await this.uploadLogo(pluginId, logoPath);
const pluginUserId = generatePluginUserId();
const user = await this.userService.createSystemUser({
id: pluginUserId,
name,
email: getPluginEmail(pluginId),
});
const { hashedSecret, maskedSecret } = await generateSecret(secret);
return this.prismaService.txClient().plugin.create({
select: {
id: true,
name: true,
description: true,
detailDesc: true,
positions: true,
helpUrl: true,
logo: true,
url: true,
status: true,
i18n: true,
secret: true,
createdTime: true,
},
data: {
id: pluginId,
name,
description,
detailDesc,
positions: JSON.stringify(positions),
helpUrl,
url,
logo,
status: PluginStatus.Published,
i18n: JSON.stringify(i18n),
secret: hashedSecret,
maskedSecret,
pluginUser: user.id,
createdBy: 'system',
},
});
}
}

View File

@ -0,0 +1,191 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { getRandomString } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import {
PluginStatus,
type IPluginGetTokenRo,
type IPluginGetTokenVo,
type IPluginRefreshTokenRo,
type IPluginRefreshTokenVo,
} from '@teable/openapi';
import { CacheService } from '../../cache/cache.service';
import { second } from '../../utils/second';
import { AccessTokenService } from '../access-token/access-token.service';
import { validateSecret } from './utils';
interface IRefreshPayload {
pluginId: string;
secret: string;
accessTokenId: string;
}
@Injectable()
export class PluginAuthService {
accessTokenExpireIn = second('10m');
refreshTokenExpireIn = second('30d');
constructor(
private readonly prismaService: PrismaService,
private readonly cacheService: CacheService,
private readonly accessTokenService: AccessTokenService,
private readonly jwtService: JwtService
) {}
private generateAccessToken({
userId,
scopes,
clientId,
name,
baseId,
}: {
userId: string;
scopes: string[];
clientId: string;
name: string;
baseId: string;
}) {
return this.accessTokenService.createAccessToken({
clientId,
name: `plugin:${name}`,
scopes,
userId,
baseIds: [baseId],
// 10 minutes
expiredTime: new Date(Date.now() + this.accessTokenExpireIn * 1000).toISOString(),
});
}
private async generateRefreshToken({ pluginId, secret, accessTokenId }: IRefreshPayload) {
return this.jwtService.signAsync(
{
secret,
accessTokenId,
pluginId,
},
{ expiresIn: this.refreshTokenExpireIn }
);
}
private async validateSecret(secret: string, pluginId: string) {
const plugin = await this.prismaService.plugin
.findFirstOrThrow({
where: { id: pluginId, status: PluginStatus.Published },
})
.catch(() => {
throw new NotFoundException('Plugin not found');
});
if (!plugin.pluginUser) {
throw new BadRequestException('Plugin user not found');
}
const checkSecret = await validateSecret(secret, plugin.secret);
if (!checkSecret) {
throw new BadRequestException('Invalid secret');
}
return {
...plugin,
pluginUser: plugin.pluginUser,
};
}
async token(pluginId: string, ro: IPluginGetTokenRo): Promise<IPluginGetTokenVo> {
const { secret, scopes, baseId } = ro;
const plugin = await this.validateSecret(secret, pluginId);
const accessToken = await this.generateAccessToken({
userId: plugin.pluginUser,
scopes,
baseId,
clientId: pluginId,
name: plugin.name,
});
const refreshToken = await this.generateRefreshToken({
pluginId,
secret,
accessTokenId: accessToken.id,
});
return {
accessToken: accessToken.token,
refreshToken,
scopes,
expiresIn: this.accessTokenExpireIn,
refreshExpiresIn: this.refreshTokenExpireIn,
};
}
async refreshToken(pluginId: string, ro: IPluginRefreshTokenRo): Promise<IPluginRefreshTokenVo> {
const { secret, refreshToken } = ro;
const plugin = await this.validateSecret(secret, pluginId);
const payload = await this.jwtService.verifyAsync<IRefreshPayload>(refreshToken).catch(() => {
// eslint-disable-next-line sonarjs/no-duplicate-string
throw new BadRequestException('Invalid refresh token');
});
if (
payload.pluginId !== pluginId ||
payload.secret !== secret ||
payload.accessTokenId === undefined
) {
throw new BadRequestException('Invalid refresh token');
}
return this.prismaService.$tx(async (prisma) => {
const oldAccessToken = await prisma.accessToken
.findFirstOrThrow({
where: { id: payload.accessTokenId },
})
.catch(() => {
throw new BadRequestException('Invalid refresh token');
});
await prisma.accessToken.delete({
where: { id: payload.accessTokenId, userId: plugin.pluginUser },
});
const baseId = oldAccessToken.baseIds ? JSON.parse(oldAccessToken.baseIds)[0] : '';
const scopes = oldAccessToken.scopes ? JSON.parse(oldAccessToken.scopes) : [];
if (!baseId) {
throw new InternalServerErrorException('Anomalous token with no baseId');
}
const accessToken = await this.generateAccessToken({
userId: plugin.pluginUser,
scopes,
baseId,
clientId: pluginId,
name: plugin.name,
});
const refreshToken = await this.generateRefreshToken({
pluginId,
secret,
accessTokenId: accessToken.id,
});
return {
accessToken: accessToken.token,
refreshToken,
scopes,
expiresIn: this.accessTokenExpireIn,
refreshExpiresIn: this.refreshTokenExpireIn,
};
});
}
async authCode(pluginId: string, baseId: string) {
const count = await this.prismaService.pluginInstall.count({
where: { pluginId, baseId },
});
if (count === 0) {
throw new NotFoundException('Plugin not installed');
}
const authCode = getRandomString(16);
await this.cacheService.set(`plugin:auth-code:${authCode}`, { baseId, pluginId }, second('5m'));
return authCode;
}
}

View File

@ -0,0 +1,19 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { PluginController } from './plugin.controller';
describe('PluginController', () => {
let controller: PluginController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PluginController],
}).compile();
controller = module.get<PluginController>(PluginController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,109 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import type {
ICreatePluginVo,
IGetPluginCenterListVo,
IGetPluginsVo,
IGetPluginVo,
IPluginGetTokenVo,
IPluginRefreshTokenVo,
IPluginRegenerateSecretVo,
IUpdatePluginVo,
} from '@teable/openapi';
import {
createPluginRoSchema,
ICreatePluginRo,
updatePluginRoSchema,
IUpdatePluginRo,
getPluginCenterListRoSchema,
IGetPluginCenterListRo,
pluginGetTokenRoSchema,
IPluginGetTokenRo,
pluginRefreshTokenRoSchema,
IPluginRefreshTokenRo,
} from '@teable/openapi';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { ResourceMeta } from '../auth/decorators/resource_meta.decorator';
import { PluginAuthService } from './plugin-auth.service';
import { PluginService } from './plugin.service';
@Controller('api/plugin')
export class PluginController {
constructor(
private readonly pluginService: PluginService,
private readonly pluginAuthService: PluginAuthService
) {}
@Post()
createPlugin(
@Body(new ZodValidationPipe(createPluginRoSchema)) data: ICreatePluginRo
): Promise<ICreatePluginVo> {
return this.pluginService.createPlugin(data);
}
@Get()
getPlugins(): Promise<IGetPluginsVo> {
return this.pluginService.getPlugins();
}
@Get(':pluginId')
getPlugin(@Param('pluginId') pluginId: string): Promise<IGetPluginVo> {
return this.pluginService.getPlugin(pluginId);
}
@Post(':pluginId/regenerate-secret')
regenerateSecret(@Param('pluginId') pluginId: string): Promise<IPluginRegenerateSecretVo> {
return this.pluginService.regenerateSecret(pluginId);
}
@Put(':pluginId')
updatePlugin(
@Param('pluginId') pluginId: string,
@Body(new ZodValidationPipe(updatePluginRoSchema)) ro: IUpdatePluginRo
): Promise<IUpdatePluginVo> {
return this.pluginService.updatePlugin(pluginId, ro);
}
@Delete(':pluginId')
deletePlugin(@Param('pluginId') pluginId: string): Promise<void> {
return this.pluginService.delete(pluginId);
}
@Get('center/list')
getPluginCenterList(
@Query(new ZodValidationPipe(getPluginCenterListRoSchema)) ro: IGetPluginCenterListRo
): Promise<IGetPluginCenterListVo> {
return this.pluginService.getPluginCenterList(ro.positions);
}
@Patch(':pluginId/submit')
submitPlugin(@Param('pluginId') pluginId: string): Promise<void> {
return this.pluginService.submitPlugin(pluginId);
}
@Post(':pluginId/token')
@Public()
accessToken(
@Param('pluginId') pluginId: string,
@Body(new ZodValidationPipe(pluginGetTokenRoSchema)) ro: IPluginGetTokenRo
): Promise<IPluginGetTokenVo> {
return this.pluginAuthService.token(pluginId, ro);
}
@Post(':pluginId/refreshToken')
@Public()
refreshToken(
@Param('pluginId') pluginId: string,
@Body(new ZodValidationPipe(pluginRefreshTokenRoSchema)) ro: IPluginRefreshTokenRo
): Promise<IPluginRefreshTokenVo> {
return this.pluginAuthService.refreshToken(pluginId, ro);
}
@Post(':pluginId/authCode')
@Permissions('base|read')
@ResourceMeta('baseId', 'body')
authCode(@Param('pluginId') pluginId: string, @Body('baseId') baseId: string): Promise<string> {
return this.pluginAuthService.authCode(pluginId, baseId);
}
}

View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { authConfig, type IAuthConfig } from '../../configs/auth.config';
import { AccessTokenModule } from '../access-token/access-token.module';
import { StorageModule } from '../attachments/plugins/storage.module';
import { UserModule } from '../user/user.module';
import { OfficialPluginInitService } from './official/official-plugin-init.service';
import { PluginAuthService } from './plugin-auth.service';
import { PluginController } from './plugin.controller';
import { PluginService } from './plugin.service';
@Module({
imports: [
UserModule,
AccessTokenModule,
StorageModule,
JwtModule.registerAsync({
useFactory: (config: IAuthConfig) => ({
secret: config.jwt.secret,
signOptions: {
expiresIn: config.jwt.expiresIn,
},
}),
inject: [authConfig.KEY],
}),
],
providers: [PluginService, PluginAuthService, OfficialPluginInitService],
controllers: [PluginController],
})
export class PluginModule {}

View File

@ -0,0 +1,21 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { GlobalModule } from '../../global/global.module';
import { PluginModule } from './plugin.module';
import { PluginService } from './plugin.service';
describe('PluginService', () => {
let service: PluginService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [GlobalModule, PluginModule],
}).compile();
service = module.get<PluginService>(PluginService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,361 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Injectable, NotFoundException } from '@nestjs/common';
import {
generatePluginId,
generatePluginUserId,
getPluginEmail,
nullsToUndefined,
} from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { UploadType, PluginStatus } from '@teable/openapi';
import type {
IGetPluginCenterListVo,
ICreatePluginRo,
ICreatePluginVo,
IGetPluginsVo,
IGetPluginVo,
IPluginI18n,
IPluginRegenerateSecretVo,
IUpdatePluginRo,
IUpdatePluginVo,
PluginPosition,
} from '@teable/openapi';
import { omit } from 'lodash';
import { ClsService } from 'nestjs-cls';
import type { IClsStore } from '../../types/cls';
import StorageAdapter from '../attachments/plugins/adapter';
import { getFullStorageUrl } from '../attachments/plugins/utils';
import { UserService } from '../user/user.service';
import { generateSecret } from './utils';
@Injectable()
export class PluginService {
constructor(
private readonly prismaService: PrismaService,
private readonly cls: ClsService<IClsStore>,
private readonly userService: UserService
) {}
private logoToVoValue(logo: string) {
return getFullStorageUrl(StorageAdapter.getBucket(UploadType.Plugin), logo);
}
private convertToVo<
T extends {
positions: string;
i18n?: string | null;
status: string;
logo: string;
createdTime?: Date | null;
lastModifiedTime?: Date | null;
},
>(ro: T) {
return nullsToUndefined({
...ro,
logo: this.logoToVoValue(ro.logo),
status: ro.status as PluginStatus,
positions: JSON.parse(ro.positions) as PluginPosition[],
i18n: ro.i18n ? (JSON.parse(ro.i18n) as IPluginI18n) : undefined,
createdTime: ro.createdTime?.toISOString(),
lastModifiedTime: ro.lastModifiedTime?.toISOString(),
});
}
private async getUserMap(userIds: string[]) {
const users = await this.prismaService.txClient().user.findMany({
where: { id: { in: userIds }, deletedTime: null },
select: {
id: true,
name: true,
email: true,
avatar: true,
},
});
const systemUser = userIds.find((id) => id === 'system')
? {
id: 'system',
name: 'Teable',
email: 'support@teable.io',
avatar: undefined,
}
: undefined;
const userMap = users.reduce(
(acc, user) => {
if (user.id === 'system') {
acc[user.id] = {
id: user.id,
name: 'Teable',
email: 'support@teable.io',
avatar: undefined,
};
return acc;
}
acc[user.id] = {
...user,
avatar: user.avatar
? getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), user.avatar)
: undefined,
};
return acc;
},
{} as Record<string, { id: string; name: string; email: string; avatar?: string }>
);
return systemUser
? {
...userMap,
system: systemUser,
}
: userMap;
}
async createPlugin(createPluginRo: ICreatePluginRo): Promise<ICreatePluginVo> {
const userId = this.cls.get('user.id');
const { name, description, detailDesc, helpUrl, logo, i18n, positions, url } = createPluginRo;
const { secret, hashedSecret, maskedSecret } = await generateSecret();
const res = await this.prismaService.$tx(async (prisma) => {
const pluginId = generatePluginId();
const pluginUserId = generatePluginUserId();
const user = await this.userService.createSystemUser({
id: pluginUserId,
name,
email: getPluginEmail(pluginId),
});
const plugin = await prisma.plugin.create({
select: {
id: true,
name: true,
description: true,
detailDesc: true,
positions: true,
helpUrl: true,
logo: true,
url: true,
status: true,
i18n: true,
secret: true,
createdTime: true,
},
data: {
id: pluginId,
name,
description,
detailDesc,
positions: JSON.stringify(positions),
helpUrl,
url,
logo,
status: PluginStatus.Developing,
i18n: JSON.stringify(i18n),
secret: hashedSecret,
maskedSecret,
pluginUser: user.id,
createdBy: userId,
},
});
return {
...plugin,
secret,
pluginUser: {
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar
? getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), user.avatar)
: undefined,
},
};
});
return this.convertToVo(res);
}
async updatePlugin(id: string, updatePluginRo: IUpdatePluginRo): Promise<IUpdatePluginVo> {
const userId = this.cls.get('user.id');
const isAdmin = this.cls.get('user.isAdmin');
const { name, description, detailDesc, helpUrl, logo, i18n, positions, url } = updatePluginRo;
const res = await this.prismaService.$tx(async (prisma) => {
const res = await prisma.plugin
.update({
select: {
id: true,
name: true,
description: true,
detailDesc: true,
positions: true,
helpUrl: true,
logo: true,
url: true,
status: true,
i18n: true,
secret: true,
pluginUser: true,
createdTime: true,
lastModifiedTime: true,
},
where: { id, createdBy: isAdmin ? { in: ['system', userId] } : userId },
data: {
name,
description,
detailDesc,
positions: JSON.stringify(positions),
helpUrl,
url,
logo,
i18n: JSON.stringify(i18n),
lastModifiedBy: userId,
},
})
.catch(() => {
throw new NotFoundException('Plugin not found');
});
if (name && res.pluginUser) {
await this.userService.updateUserName(res.pluginUser, name);
}
return res;
});
const userMap = res.pluginUser ? await this.getUserMap([res.pluginUser]) : {};
return this.convertToVo({
...res,
pluginUser: res.pluginUser ? userMap[res.pluginUser] : undefined,
});
}
async getPlugin(id: string): Promise<IGetPluginVo> {
const userId = this.cls.get('user.id');
const isAdmin = this.cls.get('user.isAdmin');
const res = await this.prismaService.plugin
.findUniqueOrThrow({
select: {
id: true,
name: true,
description: true,
detailDesc: true,
positions: true,
helpUrl: true,
logo: true,
url: true,
status: true,
i18n: true,
maskedSecret: true,
pluginUser: true,
createdTime: true,
lastModifiedTime: true,
},
where: { id, createdBy: isAdmin ? { in: ['system', userId] } : userId },
})
.catch(() => {
throw new NotFoundException('Plugin not found');
});
const userMap = res.pluginUser ? await this.getUserMap([res.pluginUser]) : {};
return this.convertToVo({
...omit(res, 'maskedSecret'),
secret: res.maskedSecret,
pluginUser: res.pluginUser ? userMap[res.pluginUser] : undefined,
});
}
async getPlugins(): Promise<IGetPluginsVo> {
const userId = this.cls.get('user.id');
const isAdmin = this.cls.get('user.isAdmin');
const res = await this.prismaService.plugin.findMany({
where: { createdBy: isAdmin ? { in: ['system', userId] } : userId },
select: {
id: true,
name: true,
description: true,
detailDesc: true,
positions: true,
helpUrl: true,
logo: true,
url: true,
status: true,
i18n: true,
secret: true,
pluginUser: true,
createdTime: true,
lastModifiedTime: true,
},
});
const userIds = res.map((r) => r.pluginUser).filter((r) => r !== null) as string[];
const userMap = await this.getUserMap(userIds);
return res.map((r) =>
this.convertToVo({
...r,
pluginUser: r.pluginUser ? userMap[r.pluginUser] : undefined,
})
);
}
async delete(id: string) {
await this.prismaService.$tx(async (prisma) => {
const res = await prisma.plugin.delete({ where: { id } });
if (res.pluginUser) {
await prisma.user.delete({ where: { id: res.pluginUser } });
}
});
}
async regenerateSecret(id: string): Promise<IPluginRegenerateSecretVo> {
const { secret, hashedSecret, maskedSecret } = await generateSecret();
await this.prismaService.plugin.update({
select: {
id: true,
secret: true,
},
where: { id },
data: {
secret: hashedSecret,
maskedSecret,
},
});
return { secret, id };
}
async getPluginCenterList(positions?: PluginPosition[]): Promise<IGetPluginCenterListVo> {
const res = await this.prismaService.plugin.findMany({
select: {
id: true,
name: true,
description: true,
detailDesc: true,
logo: true,
helpUrl: true,
i18n: true,
createdTime: true,
lastModifiedTime: true,
createdBy: true,
},
where: {
status: PluginStatus.Published,
...(positions?.length
? {
OR: positions.map((position) => ({ positions: { contains: position } })),
}
: {}),
},
});
const userIds = res.map((r) => r.createdBy);
const userMap = await this.getUserMap(userIds);
return res.map((r) =>
nullsToUndefined({
...r,
logo: this.logoToVoValue(r.logo),
i18n: r.i18n ? (JSON.parse(r.i18n) as IPluginI18n) : undefined,
createdBy: userMap[r.createdBy],
createdTime: r.createdTime?.toISOString(),
lastModifiedTime: r.lastModifiedTime?.toISOString(),
})
);
}
async submitPlugin(id: string) {
const userId = this.cls.get('user.id');
await this.prismaService.plugin.update({
where: { id, createdBy: userId },
data: { status: PluginStatus.Reviewing },
});
}
}

View File

@ -0,0 +1,15 @@
import { getRandomString } from '@teable/core';
import * as bcrypt from 'bcrypt';
export const generateSecret = async (_secret?: string) => {
const secret = _secret ?? getRandomString(40).toLocaleLowerCase();
const hashedSecret = await bcrypt.hash(secret, 10);
const sensitivePart = secret.slice(0, secret.length - 10);
const maskedSecret = secret.slice(0).replace(sensitivePart, '*'.repeat(sensitivePart.length));
return { secret, hashedSecret, maskedSecret };
};
export const validateSecret = async (secret: string, hashedSecret: string) => {
return bcrypt.compare(secret, hashedSecret);
};

View File

@ -0,0 +1,14 @@
import { Controller, Param, Patch, UseGuards } from '@nestjs/common';
import { AdminGuard } from './admin.guard';
import { AdminService } from './admin.service';
@Controller('api/admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@UseGuards(AdminGuard)
@Patch('/plugin/:pluginId/publish')
async publishPlugin(@Param('pluginId') pluginId: string): Promise<void> {
await this.adminService.publishPlugin(pluginId);
}
}

View File

@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@teable/db-main-prisma';
import { PluginStatus } from '@teable/openapi';
@Injectable()
export class AdminService {
constructor(private readonly prismaService: PrismaService) {}
async publishPlugin(pluginId: string) {
return this.prismaService.plugin.update({
where: { id: pluginId, status: PluginStatus.Reviewing },
data: { status: PluginStatus.Published },
});
}
}

View File

@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminGuard } from './admin.guard';
import { AdminService } from './admin.service';
import { SettingController } from './setting.controller';
import { SettingService } from './setting.service';
@Module({
controllers: [SettingController],
controllers: [SettingController, AdminController],
exports: [SettingService],
providers: [SettingService, AdminGuard],
providers: [SettingService, AdminGuard, AdminService],
})
export class SettingModule {}

View File

@ -118,7 +118,9 @@ export class UserService {
notifyMeta: JSON.stringify(defaultNotifyMeta),
};
const userTotalCount = await this.prismaService.txClient().user.count();
const userTotalCount = await this.prismaService.txClient().user.count({
where: { isSystem: null },
});
const isAdmin = !this.baseConfig.isCloud && userTotalCount === 0;
@ -345,4 +347,31 @@ export class UserService {
};
});
}
async createSystemUser({
id = generateUserId(),
email,
name,
avatar,
}: {
id?: string;
email: string;
name: string;
avatar?: string;
}) {
return this.prismaService.$tx(async () => {
if (!avatar) {
avatar = await this.generateDefaultAvatar(id);
}
return this.prismaService.txClient().user.create({
data: {
id,
email,
name,
avatar,
isSystem: true,
},
});
});
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -531,8 +531,6 @@ describe('BaseSqlQuery e2e', () => {
{
[`${table1.fields[0].id}_${table1.fields[0].name}`]: 'Charlie',
[`${table1.fields[1].id}_${table1.fields[1].name}`]: 40,
[`${table2.fields[0].id}_${table2.fields[0].name}`]: null,
[`${table2.fields[1].id}_${table2.fields[1].name}`]: null,
},
]);
});

View File

@ -21,19 +21,22 @@ import { EventEmitterService } from '../src/event-emitter/event-emitter.service'
import { Events } from '../src/event-emitter/events';
import { createNewUserAxios } from './utils/axios-instance/new-user';
import { createAwaitWithEvent } from './utils/event-promise';
import { createField, createTable, initApp } from './utils/init-app';
import { createBase, createField, createTable, deleteBase, initApp } from './utils/init-app';
describe('Computed user field (e2e)', () => {
let app: INestApplication;
const baseId = globalThis.testConfig.baseId;
const spaceId = globalThis.testConfig.spaceId;
const userName = globalThis.testConfig.userName;
let baseId: string;
beforeAll(async () => {
const appCtx = await initApp();
app = appCtx.app;
const base = await createBase({ name: 'base1', spaceId });
baseId = base.id;
});
afterAll(async () => {
await deleteBase(baseId);
await app.close();
});

View File

@ -0,0 +1,157 @@
import type { INestApplication } from '@nestjs/common';
import {
createDashboard,
createDashboardVoSchema,
createPlugin,
dashboardInstallPluginVoSchema,
deleteDashboard,
deletePlugin,
getDashboard,
getDashboardVoSchema,
installPlugin,
PluginPosition,
publishPlugin,
removePlugin,
renameDashboard,
renameDashboardVoSchema,
renamePlugin,
submitPlugin,
updateLayoutDashboard,
} from '@teable/openapi';
import { getError } from './utils/get-error';
import { initApp } from './utils/init-app';
const dashboardRo = {
name: 'dashboard',
};
describe('DashboardController', () => {
let app: INestApplication;
let dashboardId: string;
const baseId = globalThis.testConfig.baseId;
beforeAll(async () => {
const appCtx = await initApp();
app = appCtx.app;
});
beforeEach(async () => {
const res = await createDashboard(baseId, dashboardRo);
dashboardId = res.data.id;
});
afterEach(async () => {
await deleteDashboard(baseId, dashboardId);
});
afterAll(async () => {
await app.close();
});
it('/api/dashboard (POST)', async () => {
const res = await createDashboard(baseId, dashboardRo);
expect(createDashboardVoSchema.strict().safeParse(res.data).success).toBe(true);
expect(res.status).toBe(201);
await deleteDashboard(baseId, res.data.id);
});
it('/api/dashboard/:id (GET)', async () => {
const getRes = await getDashboard(baseId, dashboardId);
expect(getDashboardVoSchema.strict().safeParse(getRes.data).success).toBe(true);
expect(getRes.data.id).toBe(dashboardId);
});
it('/api/dashboard/:id (DELETE)', async () => {
const res = await createDashboard(baseId, dashboardRo);
await deleteDashboard(baseId, res.data.id);
const error = await getError(() => getDashboard(baseId, res.data.id));
expect(error?.status).toBe(404);
});
it('/api/dashboard/:id/rename (PATCH)', async () => {
const res = await createDashboard(baseId, dashboardRo);
const newName = 'new-dashboard';
const renameRes = await renameDashboard(baseId, res.data.id, newName);
expect(renameRes.data.name).toBe(newName);
await deleteDashboard(baseId, res.data.id);
});
it('/api/dashboard/:id/layout (PATCH)', async () => {
const res = await createDashboard(baseId, dashboardRo);
const layout = [{ pluginInstallId: 'plugin-install-id', x: 0, y: 0, w: 1, h: 1 }];
const updateRes = await updateLayoutDashboard(baseId, res.data.id, layout);
expect(updateRes.data.layout).toEqual(layout);
await deleteDashboard(baseId, res.data.id);
});
describe('plugin', () => {
let pluginId: string;
beforeEach(async () => {
const res = await createPlugin({
name: 'plugin',
logo: 'https://logo.com',
positions: [PluginPosition.Dashboard],
});
pluginId = res.data.id;
await submitPlugin(pluginId);
await publishPlugin(pluginId);
});
afterEach(async () => {
await deletePlugin(pluginId);
});
it('/api/dashboard/:id/plugin (POST)', async () => {
const installRes = await installPlugin(baseId, dashboardId, {
name: 'plugin1111',
pluginId,
});
const dashboard = await getDashboard(baseId, dashboardId);
expect(getDashboardVoSchema.safeParse(dashboard.data).success).toBe(true);
expect(installRes.data.name).toBe('plugin1111');
expect(dashboardInstallPluginVoSchema.safeParse(installRes.data).success).toBe(true);
});
it('/api/dashboard/:id/plugin (POST) - plugin not found', async () => {
const res = await createPlugin({
name: 'plugin-no',
logo: 'https://logo.com',
positions: [PluginPosition.Dashboard],
});
const error = await getError(() =>
installPlugin(baseId, dashboardId, {
name: 'dddd',
pluginId: res.data.id,
})
);
await deletePlugin(res.data.id);
expect(error?.status).toBe(404);
});
it('/api/dashboard/:id/plugin/:pluginInstallId/rename (PATCH)', async () => {
const installRes = await installPlugin(baseId, dashboardId, {
name: 'plugin1111',
pluginId,
});
const newName = 'new-plugin';
const renameRes = await renamePlugin(
baseId,
dashboardId,
installRes.data.pluginInstallId,
newName
);
expect(renameDashboardVoSchema.safeParse(renameRes.data).success).toBe(true);
expect(renameRes.data.name).toBe(newName);
});
it('/api/dashboard/:id/plugin/:pluginInstallId (DELETE)', async () => {
const installRes = await installPlugin(baseId, dashboardId, {
name: 'plugin1111',
pluginId,
});
await removePlugin(baseId, dashboardId, installRes.data.pluginInstallId);
const dashboard = await getDashboard(baseId, dashboardId);
expect(dashboard?.data?.pluginMap?.[pluginId]).toBeUndefined();
});
});
});

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/no-duplicate-string */
import type { INestApplication } from '@nestjs/common';
import { DriverClient, HttpError } from '@teable/core';
import { HttpError } from '@teable/core';
import {
GET_TABLE_LIST,
generateOAuthSecret,
@ -195,52 +195,49 @@ describe('OpenAPI OAuthController (e2e)', () => {
expect(error?.message).toBe('Invalid user');
});
it.skipIf(globalThis.testConfig.driver === DriverClient.Sqlite)(
'/api/oauth/access_token (POST)',
async () => {
const { transactionID } = await gerAuthorize(axios, oauth);
it('/api/oauth/access_token (POST)', async () => {
const { transactionID } = await gerAuthorize(axios, oauth);
const res = await decision(axios, transactionID!);
const res = await decision(axios, transactionID!);
const url = new URL(res.headers.location);
const code = url.searchParams.get('code');
const secret = await generateOAuthSecret(oauth.clientId);
const url = new URL(res.headers.location);
const code = url.searchParams.get('code');
const secret = await generateOAuthSecret(oauth.clientId);
const tokenRes = await anonymousAxios.post(
`/oauth/access_token`,
{
grant_type: 'authorization_code',
code,
client_id: oauth.clientId,
client_secret: secret.data.secret,
redirect_uri: oauth.redirectUris[0],
},
{
maxRedirects: 0,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
expect(tokenRes.status).toBe(201);
expect(tokenRes.data).toEqual({
token_type: 'Bearer',
scopes: oauth.scopes,
access_token: expect.any(String),
refresh_token: expect.any(String),
expires_in: expect.any(Number),
refresh_expires_in: expect.any(Number),
});
const userInfo = await anonymousAxios.get(`/auth/user`, {
const tokenRes = await anonymousAxios.post(
`/oauth/access_token`,
{
grant_type: 'authorization_code',
code,
client_id: oauth.clientId,
client_secret: secret.data.secret,
redirect_uri: oauth.redirectUris[0],
},
{
maxRedirects: 0,
headers: {
Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`,
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'application/x-www-form-urlencoded',
},
});
expect(userInfo.data.email).toEqual(globalThis.testConfig.email);
}
);
}
);
expect(tokenRes.status).toBe(201);
expect(tokenRes.data).toEqual({
token_type: 'Bearer',
scopes: oauth.scopes,
access_token: expect.any(String),
refresh_token: expect.any(String),
expires_in: expect.any(Number),
refresh_expires_in: expect.any(Number),
});
const userInfo = await anonymousAxios.get(`/auth/user`, {
headers: {
Authorization: `${tokenRes.data.token_type} ${tokenRes.data.access_token}`,
},
});
expect(userInfo.data.email).toEqual(globalThis.testConfig.email);
});
it('/api/oauth/access_token (POST) - has decision', async () => {
const { transactionID } = await gerAuthorize(axios, oauth);

View File

@ -0,0 +1,146 @@
import type { INestApplication } from '@nestjs/common';
import {
createPlugin,
createPluginVoSchema,
deletePlugin,
getPlugin,
getPluginCenterList,
getPluginCenterListVoSchema,
getPlugins,
getPluginsVoSchema,
getPluginVoSchema,
PluginPosition,
PluginStatus,
publishPlugin,
submitPlugin,
updatePlugin,
} from '@teable/openapi';
import { getError } from './utils/get-error';
import { initApp } from './utils/init-app';
const mockPlugin = {
name: 'plugin',
logo: '/plugin/xxxxxxx',
description: 'desc',
detailDesc: 'detail',
helpUrl: 'https://help.com',
positions: [PluginPosition.Dashboard],
i18n: {
en: {
name: 'plugin',
description: 'desc',
detailDesc: 'detail',
},
},
};
describe('PluginController', () => {
let app: INestApplication;
let pluginId: string;
beforeAll(async () => {
const appCtx = await initApp();
app = appCtx.app;
});
beforeEach(async () => {
const res = await createPlugin(mockPlugin);
pluginId = res.data.id;
});
afterEach(async () => {
await deletePlugin(pluginId);
});
afterAll(async () => {
await app.close();
});
it('/api/plugin (POST)', async () => {
const res = await createPlugin(mockPlugin);
expect(createPluginVoSchema.strict().safeParse(res.data).success).toBe(true);
expect(res.data.status).toBe(PluginStatus.Developing);
expect(res.data.pluginUser).not.toBeUndefined();
await deletePlugin(res.data.id);
});
it('/api/plugin/{pluginId} (GET)', async () => {
const getRes = await getPlugin(pluginId);
expect(getPluginVoSchema.strict().safeParse(getRes.data).success).toBe(true);
expect(getRes.data.status).toBe(PluginStatus.Developing);
expect(getRes.data.pluginUser).not.toBeUndefined();
expect(getRes.data.pluginUser?.name).toEqual('plugin');
});
it('/api/plugin/{pluginId} (GET) - 404', async () => {
const error = await getError(() => getPlugin('invalid-id'));
expect(error?.status).toBe(404);
});
it('/api/plugin (GET)', async () => {
const getRes = await getPlugins();
expect(getPluginsVoSchema.safeParse(getRes.data).success).toBe(true);
expect(getRes.data).toHaveLength(2);
});
it('/api/plugin/{pluginId} (DELETE)', async () => {
const res = await createPlugin(mockPlugin);
await deletePlugin(res.data.id);
const error = await getError(() => getPlugin(res.data.id));
expect(error?.status).toBe(404);
});
it('/api/plugin/{pluginId} (PUT)', async () => {
const res = await createPlugin(mockPlugin);
const updatePluginRo = {
name: 'updated',
description: 'updated',
detailDesc: 'updated',
helpUrl: 'https://updated.com',
logo: '/plugin/updated',
positions: [PluginPosition.Dashboard],
i18n: {
en: {
name: 'updated',
description: 'updated',
detailDesc: 'updated',
},
},
};
const putRes = await updatePlugin(res.data.id, updatePluginRo);
await deletePlugin(res.data.id);
expect(putRes.data.name).toBe(updatePluginRo.name);
expect(putRes.data.description).toBe(updatePluginRo.description);
expect(putRes.data.detailDesc).toBe(updatePluginRo.detailDesc);
expect(putRes.data.helpUrl).toBe(updatePluginRo.helpUrl);
expect(putRes.data.logo).toEqual(expect.stringContaining(updatePluginRo.logo));
expect(putRes.data.i18n).toEqual(updatePluginRo.i18n);
});
it('/api/plugin/{pluginId}/submit (POST)', async () => {
const res = await createPlugin(mockPlugin);
const submitRes = await submitPlugin(res.data.id);
await deletePlugin(res.data.id);
expect(submitRes.status).toBe(200);
});
it('/api/admin/plugin/{pluginId}/publish (PATCH)', async () => {
const res = await createPlugin(mockPlugin);
await submitPlugin(res.data.id);
await publishPlugin(res.data.id);
const getRes = await getPlugin(res.data.id);
await deletePlugin(res.data.id);
expect(getRes.data.status).toBe(PluginStatus.Published);
});
it('/api/admin/plugin/center/list (GET)', async () => {
const res = await createPlugin(mockPlugin);
await submitPlugin(res.data.id);
await publishPlugin(res.data.id);
const getRes = await getPluginCenterList();
await deletePlugin(res.data.id);
expect(getRes.data).toHaveLength(2);
const plugin = getRes.data.find((p) => p.id === res.data.id);
expect(plugin).not.toBeUndefined();
expect(getPluginCenterListVoSchema.safeParse(getRes.data).success).toBe(true);
});
});

View File

@ -132,6 +132,7 @@
"express": "4.19.1",
"filesize": "10.1.1",
"fuse.js": "7.0.0",
"github-markdown-css": "5.6.1",
"i18next": "23.10.1",
"is-port-reachable": "3.1.0",
"jschardet": "3.1.3",
@ -145,6 +146,7 @@
"next-seo": "6.5.0",
"next-transpile-modules": "10.0.1",
"nprogress": "0.2.0",
"penpal": "6.2.2",
"picocolors": "1.0.0",
"qrcode.react": "3.1.0",
"react": "18.2.0",
@ -168,6 +170,7 @@
"recharts": "2.12.3",
"reconnecting-websocket": "4.4.0",
"reflect-metadata": "0.2.1",
"rehype-raw": "7.0.0",
"remark-gfm": "4.0.0",
"sharedb": "4.1.2",
"tailwind-scrollbar": "3.1.0",

View File

@ -1,139 +0,0 @@
import { BaseQueryColumnType, type IBaseQueryVo } from '@teable/openapi';
import { SelectTrigger } from '@teable/ui-lib';
import {
Label,
Select,
SelectContent,
SelectItem,
SelectValue,
type ChartConfig,
} from '@teable/ui-lib/shadcn';
import { useMemo, useState } from 'react';
import { ChartBar } from './chart-show/Bar';
export const ChartDisplay = (props: { data: IBaseQueryVo }) => {
const { data } = props;
const { rows, columns } = data;
const [xAxis, setXAxis] = useState<string>();
const [yAxis, setYAxis] = useState<string>();
const [group, setGroup] = useState<string>();
const yColumns = columns.filter(
(column) =>
column.type === BaseQueryColumnType.Aggregation ||
(column.fieldSource &&
column.fieldSource?.cellValueType === 'number' &&
!column.fieldSource.isMultipleCellValue)
);
const chartConfig = useMemo(() => {
const column = columns.find((column) => column.column === yAxis);
if (!column) return;
if (!group) {
return {
[column.column]: {
label: column.name,
color: 'hsl(var(--chart-1))',
},
};
}
if (!xAxis) return;
const chartConfig: ChartConfig = {};
rows.forEach((row) => {
const groupValue = row[group] as string;
if (!chartConfig[groupValue]) {
chartConfig[groupValue] = {
label: groupValue,
color: `hsl(var(--chart-${Object.keys(chartConfig).length + 1}))`,
};
}
});
return chartConfig;
}, [columns, group, rows, yAxis, xAxis]);
const convertRows = useMemo(() => {
if (!chartConfig || !group || !xAxis || !yAxis) return rows;
const xAxisColumn = columns.find((column) => column.column === xAxis);
if (!xAxisColumn) return rows;
const rowsMap: Record<string, Record<string, unknown>> = {};
rows.forEach((row) => {
const groupValue = row[group] as string;
const key = row[xAxis] as string;
const existRow = rowsMap[key];
if (existRow) {
rowsMap[key] = {
...existRow,
[groupValue]: row[yAxis],
};
} else {
rowsMap[key] = {
[xAxis]: row[xAxis],
...Object.keys(chartConfig).reduce(
(pre, cur) => {
pre[cur] = groupValue === cur ? (row[yAxis] as number) : 0;
return pre;
},
{} as Record<string, number>
),
};
}
});
return Object.values(rowsMap);
}, [chartConfig, columns, group, rows, xAxis, yAxis]);
return (
<div className="p-4">
<div className="mb-4 flex gap-4">
<div className="flex items-center gap-2">
<Label>x</Label>
<Select value={xAxis} onValueChange={setXAxis}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
{columns.map((column) => (
<SelectItem key={column.column} value={column.column}>
{column.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label>y</Label>
<Select value={yAxis} onValueChange={setYAxis}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
{yColumns.map((column) => (
<SelectItem key={column.column} value={column.column}>
{column.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label>Group</Label>
<Select value={group} onValueChange={setGroup}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
{columns.map((column) => (
<SelectItem key={column.column} value={column.column}>
{column.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{chartConfig && xAxis && (
<ChartBar xAxis={xAxis} chartData={convertRows} chartConfig={chartConfig} />
)}
</div>
);
};

View File

@ -1,45 +0,0 @@
import type { IGetBaseVo, ITableVo } from '@teable/openapi';
import { SessionProvider } from '@teable/sdk';
import type { IUser } from '@teable/sdk';
import { AnchorContext, AppProvider, BaseProvider, TableProvider } from '@teable/sdk/context';
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AppLayout } from '@/features/app/layouts';
import { useSdkLocale } from '../../hooks/useSdkLocale';
import { BasePermissionListener } from '../base/BasePermissionListener';
export const ChartLayout: React.FC<{
children: React.ReactNode;
tableServerData: ITableVo[];
baseServerData: IGetBaseVo;
user?: IUser;
}> = ({ children, tableServerData, baseServerData, user }) => {
const router = useRouter();
const { baseId } = router.query;
const sdkLocale = useSdkLocale();
const { i18n } = useTranslation();
return (
<AppLayout>
<AppProvider lang={i18n.language} locale={sdkLocale}>
<SessionProvider user={user}>
<AnchorContext.Provider
value={{
baseId: baseId as string,
}}
>
<BaseProvider serverData={baseServerData}>
<BasePermissionListener />
<TableProvider serverData={tableServerData}>
<div id="portal" className="relative flex h-screen w-full items-start">
{children}
</div>
</TableProvider>
</BaseProvider>
</AnchorContext.Provider>
</SessionProvider>
</AppProvider>
</AppLayout>
);
};

View File

@ -1,40 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { baseQuery, type IBaseQuery } from '@teable/openapi';
import type { IBaseQueryBuilderRef } from '@teable/sdk/components';
import { BaseQueryBuilder } from '@teable/sdk/components';
import { useBaseId } from '@teable/sdk/hooks';
import { Button } from '@teable/ui-lib';
import { useRef, useState } from 'react';
import { ChartDisplay } from './ChartDisplay';
export const ChartPage = () => {
const [query, setQuery] = useState<IBaseQuery>();
const baseId = useBaseId();
const queryBuilderRef = useRef<IBaseQueryBuilderRef>(null);
const { mutate: baseQueryMutate, data } = useMutation({
mutationFn: ({ baseId, query }: { baseId: string; query: IBaseQuery }) =>
baseQuery(baseId, query),
});
return (
<div className="flex size-full flex-col overflow-auto">
<h1 className="p-2 text-center">Chart Page</h1>
<div className="m-10">
<BaseQueryBuilder ref={queryBuilderRef} query={query} onChange={setQuery} />
</div>
<Button
className="mx-10 shrink-0"
size={'sm'}
onClick={() => {
if (queryBuilderRef.current?.validateQuery() && query && baseId) {
baseQueryMutate({ baseId, query });
}
}}
>
To Query
</Button>
{data?.data && <ChartDisplay data={data?.data} />}
</div>
);
};

View File

@ -1,74 +0,0 @@
import {
Card,
CardContent,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from '@teable/ui-lib';
import type { ChartConfig } from '@teable/ui-lib';
import { useState } from 'react';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import type { Payload } from 'recharts/types/component/DefaultLegendContent';
export const ChartBar = (props: {
chartData: Record<string, unknown>[];
chartConfig: ChartConfig;
xAxis: string;
}) => {
const { chartData, chartConfig, xAxis } = props;
const [hoverLegend, setHoverLegend] = useState<string>();
const [hiddenLegends, setHiddenLegends] = useState<string[]>([]);
const handleLegendMouseEnter = (o: Payload) => {
const { dataKey } = o;
setHoverLegend(dataKey as string);
};
const handleLegendMouseLeave = () => {
setHoverLegend(undefined);
};
const handleLegendClick = (o: Payload) => {
const { dataKey } = o;
if (hiddenLegends.includes(dataKey as string)) {
setHiddenLegends(hiddenLegends.filter((legend) => legend !== dataKey));
} else {
setHiddenLegends([...hiddenLegends, dataKey as string]);
}
};
return (
<Card>
<CardContent className="p-4">
<ChartContainer config={chartConfig}>
<BarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey={xAxis} tickLine={false} tickMargin={10} axisLine={false} />
<YAxis fontSize={12} tickLine={false} axisLine={false} tickMargin={10} />
<ChartLegend
onMouseEnter={handleLegendMouseEnter}
onMouseLeave={handleLegendMouseLeave}
onClick={handleLegendClick}
content={<ChartLegendContent className="cursor-pointer" />}
/>
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="dashed" />} />
{Object.keys(chartConfig).map((key) => (
<Bar
key={key}
dataKey={key}
fill={`var(--color-${key})`}
radius={4}
className="transition-[fill-opacity]"
fillOpacity={hoverLegend && hoverLegend !== key ? 0.5 : 1}
hide={hiddenLegends.includes(key)}
/>
))}
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
};

View File

@ -1,19 +1,18 @@
import { Spin } from '@teable/ui-lib/base';
import { Button, Separator } from '@teable/ui-lib/shadcn';
import { useTranslation } from 'next-i18next';
import { oauthAppConfig } from '@/features/i18n/oauth-app.config';
interface IOAuthAppDetailLayoutProps {
interface IFormPageLayoutProps {
children: React.ReactNode | React.ReactNode[] | null;
loading?: boolean;
onSubmit?: () => void;
onCancel?: () => void;
}
export const OAuthAppDetailLayout = (props: IOAuthAppDetailLayoutProps) => {
export const FormPageLayout = (props: IFormPageLayoutProps) => {
const { children, onCancel, onSubmit, loading } = props;
const { t } = useTranslation(oauthAppConfig.i18nNamespaces);
const { t } = useTranslation('common');
return (
<div className="mx-auto max-w-3xl space-y-10 px-0.5">
@ -21,11 +20,11 @@ export const OAuthAppDetailLayout = (props: IOAuthAppDetailLayoutProps) => {
<Separator />
<div className="space-x-3 text-right">
<Button size={'sm'} variant={'ghost'} onClick={onCancel}>
{t('common:actions.cancel')}
{t('actions.cancel')}
</Button>
<Button size={'sm'} onClick={onSubmit} disabled={loading}>
{loading && <Spin />}
{t('common:actions.submit')}
{t('actions.submit')}
</Button>
</div>
</div>

View File

@ -10,7 +10,7 @@ import { useTranslation } from 'next-i18next';
import { useRef, useState } from 'react';
import { CopyButton } from '@/features/app/components/CopyButton';
import { oauthAppConfig } from '@/features/i18n/oauth-app.config';
import { OAuthAppDetailLayout } from './OAuthAppDetailLayout';
import { FormPageLayout } from '../../components/FormPageLayout';
import type { IOAuthAppFormRef } from './OAuthAppForm';
import { OAuthAppForm } from './OAuthAppForm';
@ -58,7 +58,7 @@ export const OAuthAppEdit = (props: IOAuthAppEditProps) => {
});
return (
<OAuthAppDetailLayout
<FormPageLayout
onCancel={onBack}
loading={isLoading}
onSubmit={() => {
@ -154,6 +154,6 @@ export const OAuthAppEdit = (props: IOAuthAppEditProps) => {
{!queryLoading && (
<OAuthAppForm ref={formRef} showBasicTitle value={oauthApp} onChange={setUpdatedForm} />
)}
</OAuthAppDetailLayout>
</FormPageLayout>
);
};

View File

@ -1,7 +1,7 @@
import { useMutation } from '@tanstack/react-query';
import { oauthCreate, type OAuthCreateRo } from '@teable/openapi';
import { useRef, useState } from 'react';
import { OAuthAppDetailLayout } from './OAuthAppDetailLayout';
import { FormPageLayout } from '../../components/FormPageLayout';
import type { IOAuthAppFormRef } from './OAuthAppForm';
import { OAuthAppForm } from './OAuthAppForm';
@ -25,7 +25,7 @@ export const OAuthAppNew = (props: IOAuthAppNewProps) => {
},
});
return (
<OAuthAppDetailLayout
<FormPageLayout
onCancel={onBack}
onSubmit={() => {
formRef.current?.validate() && mutate(form);
@ -33,6 +33,6 @@ export const OAuthAppNew = (props: IOAuthAppNewProps) => {
loading={isLoading}
>
<OAuthAppForm ref={formRef} value={form} onChange={setForm} />
</OAuthAppDetailLayout>
</FormPageLayout>
);
};

View File

@ -0,0 +1,48 @@
import { useTheme } from '@teable/next-themes';
import { Tabs, TabsContent, TabsList, TabsTrigger, Textarea } from '@teable/ui-lib/shadcn';
import { useTranslation } from 'next-i18next';
import { useEffect } from 'react';
import { MarkdownPreview } from '@/features/app/components/MarkdownPreview';
import { settingPluginConfig } from '@/features/i18n/setting-plugin.config';
export const MarkDownEditor = (props: {
defaultStatus?: 'write' | 'preview';
value?: string;
onChange: (value: string) => void;
}) => {
const { defaultStatus = 'write', value, onChange } = props;
const { resolvedTheme: currentTheme } = useTheme();
const { t } = useTranslation(settingPluginConfig.i18nNamespaces);
useEffect(() => {
if (currentTheme === 'dark') {
require('github-markdown-css/github-markdown-dark.css');
} else {
require('github-markdown-css/github-markdown-light.css');
}
}, [currentTheme]);
return (
<div>
<Tabs defaultValue={defaultStatus}>
<TabsList className="grid w-56 grid-cols-2">
<TabsTrigger className="h-full text-xs" value="write">
{t('plugin:markdown.write')}
</TabsTrigger>
<TabsTrigger className="h-full text-xs" value="preview">
{t('plugin:markdown.preview')}
</TabsTrigger>
</TabsList>
<TabsContent value="write">
<Textarea
className="h-[200px] max-h-[700px] w-full"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</TabsContent>
<TabsContent value="preview">
<MarkdownPreview>{value}</MarkdownPreview>
</TabsContent>
</Tabs>
</div>
);
};

View File

@ -0,0 +1,257 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCcw } from '@teable/icons';
import type { IUpdatePluginRo } from '@teable/openapi';
import {
getPlugin,
pluginRegenerateSecret,
updatePlugin,
updatePluginRoSchema,
} from '@teable/openapi';
import { UserAvatar } from '@teable/sdk/components';
import {
Button,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Label,
Textarea,
} from '@teable/ui-lib/shadcn';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useMount } from 'react-use';
import { settingPluginConfig } from '@/features/i18n/setting-plugin.config';
import { FormPageLayout } from '../components/FormPageLayout';
import { RequireCom } from '../components/RequireCom';
import { JsonEditor } from './component/JsonEditor';
import { LogoEditor } from './component/LogoEditor';
import { NewSecret } from './component/NewSecret';
import { PositionSelector } from './component/PositionSelector';
import { MarkDownEditor } from './MarkDownEditor';
export const PluginEdit = (props: { secret?: string }) => {
const router = useRouter();
const pluginId = router.query.id as string;
const queryClient = useQueryClient();
const [newSecret, setNewSecret] = useState<string | undefined>(props.secret);
const { t } = useTranslation(settingPluginConfig.i18nNamespaces);
const secretRef = useRef<HTMLDivElement>(null);
useMount(() => {
secretRef.current?.scrollIntoView({ block: 'center', inline: 'start' });
});
const { data: initFormValue } = useQuery({
queryKey: ['plugin', pluginId],
queryFn: () => getPlugin(pluginId).then((res) => res.data),
enabled: !!pluginId,
});
const form = useForm<IUpdatePluginRo>({
resolver: zodResolver(updatePluginRoSchema),
mode: 'onChange',
values: initFormValue,
});
const { mutate } = useMutation({
mutationFn: (ro: IUpdatePluginRo) => updatePlugin(pluginId, ro),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugin', pluginId] });
router.push({ pathname: router.pathname });
},
});
const { mutate: regenerateSecret } = useMutation({
mutationFn: pluginRegenerateSecret,
onSuccess: (res) => {
setNewSecret(res.data.secret);
queryClient.invalidateQueries({ queryKey: ['plugin', pluginId] });
},
});
const onSubmit = async (data: IUpdatePluginRo) => {
mutate(data);
};
const pluginUser = initFormValue?.pluginUser;
return (
<FormPageLayout
onSubmit={form.handleSubmit(onSubmit)}
onCancel={() => router.push({ pathname: router.pathname })}
>
<div className="space-y-2">
<NewSecret secret={newSecret} ref={secretRef} />
<div>
{pluginUser && (
<div className="space-y-2">
<Label>{t('plugin:pluginUser.name')}</Label>
<div className="text-xs text-muted-foreground">
{t('plugin:pluginUser.description')}
</div>
<div className="flex items-center gap-2">
<UserAvatar avatar={pluginUser.avatar} name={pluginUser.name} />
<div className="text-sm font-normal">{pluginUser.name}</div>
</div>
</div>
)}
</div>
<div className="space-y-2">
<Label>
{t('plugin:secret')}
<Button
className="ml-2 h-auto p-1.5"
title={t('plugin:regenerateSecret')}
size={'xs'}
variant={'outline'}
onClick={() => regenerateSecret(pluginId)}
>
<RefreshCcw />
</Button>
</Label>
<div className="text-sm font-normal">{initFormValue?.secret}</div>
</div>
</div>
<Form {...form}>
<form className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('plugin:form.name.label')}
<RequireCom />
</FormLabel>
<FormDescription>{t('plugin:form.name.description')}</FormDescription>
<FormControl>
<Input
{...field}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value || null)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('plugin:form.description.label')}</FormLabel>
<FormDescription>{t('plugin:form.description.description')}</FormDescription>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="detailDesc"
render={({ field }) => (
<FormItem>
<FormLabel>{t('plugin:form.detailDesc.label')}</FormLabel>
<FormDescription>{t('plugin:form.detailDesc.description')}</FormDescription>
<FormControl>
<MarkDownEditor value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="logo"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('plugin:form.logo.label')}
<RequireCom />
</FormLabel>
<FormDescription>{t('plugin:form.logo.description')}</FormDescription>
<FormControl>
<LogoEditor value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="helpUrl"
render={({ field }) => (
<FormItem>
<FormLabel>{t('plugin:form.helpUrl.label')}</FormLabel>
<FormDescription>{t('plugin:form.helpUrl.description')}</FormDescription>
<FormControl>
<Input
{...field}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value || null)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="positions"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('plugin:form.positions.label')}
<RequireCom />
</FormLabel>
<FormDescription>{t('plugin:form.positions.description')}</FormDescription>
<FormControl>
<PositionSelector value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="i18n"
render={({ field }) => (
<FormItem>
<FormLabel>{t('plugin:form.i18n.label')}</FormLabel>
<FormDescription>{t('plugin:form.i18n.description')}</FormDescription>
<FormControl>
<JsonEditor value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('plugin:form.url.label')}</FormLabel>
<FormDescription>{t('plugin:form.url.description')}</FormDescription>
<FormControl>
<Input value={field.value ?? ''} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</FormPageLayout>
);
};

View File

@ -0,0 +1,93 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Settings, Trash2 } from '@teable/icons';
import { deletePlugin, getPlugins } from '@teable/openapi';
import { Button, Card, CardContent } from '@teable/ui-lib/shadcn';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { settingPluginConfig } from '@/features/i18n/setting-plugin.config';
export const PluginList = () => {
const router = useRouter();
const { t } = useTranslation(settingPluginConfig.i18nNamespaces);
const queryClient = useQueryClient();
const { data: pluginList } = useQuery({
queryKey: ['plugin-list'],
queryFn: () => getPlugins().then((res) => res.data),
});
const { mutate: deletePluginMutate } = useMutation({
mutationFn: deletePlugin,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugin-list'] });
},
});
return (
<div>
<div className="flex justify-end">
<Button
size={'xs'}
onClick={() => {
router.push({ pathname: router.pathname, query: { form: 'new' } });
}}
>
<Plus />
{t('plugin:add')}
</Button>
</div>
<div className="mt-6 grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-3">
{pluginList?.map((plugin) => (
<Card key={plugin.id} className="group shadow-none hover:shadow-md">
<CardContent className="relative flex size-full items-center gap-5 px-2 py-3">
<div className="relative size-16 overflow-hidden rounded-sm">
<Image
src={plugin.logo}
alt={plugin.name}
fill
sizes="100%"
style={{
objectFit: 'contain',
}}
/>
</div>
<div className="h-full flex-1 overflow-hidden">
<div className="line-clamp-2 break-words text-sm">{plugin.name}</div>
<div
className="line-clamp-3 break-words text-xs text-muted-foreground"
title={plugin.description}
>
{plugin.description}
</div>
</div>
<div className="absolute right-2 top-2 space-x-1.5">
<Button
className="h-5 p-0.5"
variant={'ghost'}
onClick={() => {
router.push({
pathname: router.pathname,
query: { form: 'edit', id: plugin.id },
});
}}
>
<Settings />
</Button>
<Button
className="h-5 p-0.5"
variant={'ghost'}
onClick={() => {
deletePluginMutate(plugin.id);
}}
>
<Trash2 className="text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,185 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import type { ICreatePluginRo } from '@teable/openapi';
import { createPlugin, createPluginRoSchema } from '@teable/openapi';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Textarea,
} from '@teable/ui-lib/shadcn';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { useForm } from 'react-hook-form';
import { settingPluginConfig } from '@/features/i18n/setting-plugin.config';
import { FormPageLayout } from '../components/FormPageLayout';
import { RequireCom } from '../components/RequireCom';
import { JsonEditor } from './component/JsonEditor';
import { LogoEditor } from './component/LogoEditor';
import { PositionSelector } from './component/PositionSelector';
import { MarkDownEditor } from './MarkDownEditor';
export const PluginNew = (props: { onCreated?: (secret: string) => void }) => {
const { onCreated } = props;
const router = useRouter();
const form = useForm<ICreatePluginRo>({
resolver: zodResolver(createPluginRoSchema),
});
const { t } = useTranslation(settingPluginConfig.i18nNamespaces);
const { mutate } = useMutation({
mutationFn: createPlugin,
onSuccess: (res) => {
router.push({
pathname: router.pathname,
query: { form: 'edit', id: res.data.id },
});
onCreated?.(res.data.secret);
},
});
const onSubmit = async (data: ICreatePluginRo) => {
mutate(data);
};
return (
<FormPageLayout
onSubmit={form.handleSubmit(onSubmit)}
onCancel={() => router.push({ pathname: router.pathname })}
>
<Form {...form}>
<form className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('plugin:form.name.label')}
<RequireCom />
</FormLabel>
<FormDescription>{t('plugin:form.name.description')}</FormDescription>
<FormControl>
<Input
{...field}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value || null)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('plugin:form.description.label')}</FormLabel>
<FormDescription>{t('plugin:form.description.description')}</FormDescription>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="detailDesc"
render={({ field }) => (
<FormItem>
<FormLabel>{t('plugin:form.detailDesc.label')}</FormLabel>
<FormDescription>{t('plugin:form.detailDesc.description')}</FormDescription>
<FormControl>
<MarkDownEditor value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="logo"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('plugin:form.logo.label')}
<RequireCom />
</FormLabel>
<FormDescription>{t('plugin:form.logo.description')}</FormDescription>
<FormControl>
<LogoEditor value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="helpUrl"
render={({ field }) => (
<FormItem>
<FormLabel>{t('plugin:form.helpUrl.label')}</FormLabel>
<FormDescription>{t('plugin:form.helpUrl.description')}</FormDescription>
<FormControl>
<Input {...field} onChange={(e) => field.onChange(e.target.value || undefined)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="positions"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('plugin:form.positions.label')}
<RequireCom />
</FormLabel>
<FormDescription>{t('plugin:form.positions.description')}</FormDescription>
<FormControl>
<PositionSelector value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="i18n"
render={({ field }) => (
<FormItem>
<FormLabel>{t('plugin:form.i18n.label')}</FormLabel>
<FormDescription>{t('plugin:form.i18n.description')}</FormDescription>
<FormControl>
<JsonEditor value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('plugin:form.url.label')}</FormLabel>
<FormDescription>{t('plugin:form.url.description')}</FormDescription>
<FormControl>
<Input value={field.value ?? ''} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</FormPageLayout>
);
};

View File

@ -0,0 +1,68 @@
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo, useState } from 'react';
import { useInitializationZodI18n } from '@/features/app/hooks/useInitializationZodI18n';
import { settingPluginConfig } from '@/features/i18n/setting-plugin.config';
import { SettingRight } from '../SettingRight';
import { SettingRightTitle } from '../SettingRightTitle';
import { PluginEdit } from './PluginEdit';
import { PluginList } from './PluginList';
import { PluginNew } from './PluginNew';
export type IFormType = 'new' | 'edit';
export const PluginPage = () => {
const router = useRouter();
const [createdSecret, setCreatedSecret] = useState<string>();
const formType = router.query.form as IFormType;
const { t } = useTranslation(settingPluginConfig.i18nNamespaces);
useInitializationZodI18n();
const onBack = () => {
router.push({ pathname: router.pathname });
};
useEffect(() => {
const handleRouteChange = (path: string) => {
if (router.query.form !== 'new' || !path.startsWith('/setting/plugin?form=edit')) {
setCreatedSecret(undefined);
}
};
router.events.on('routeChangeStart', handleRouteChange);
return () => {
router.events.off('routeChangeStart', handleRouteChange);
};
}, [router]);
const title = useMemo(() => {
switch (formType) {
case 'new':
return t('plugin:title.add');
case 'edit':
return t('plugin:title.edit');
default:
return t('setting:plugins');
}
}, [formType, t]);
const FormPage = useMemo(() => {
switch (formType) {
case 'new':
return <PluginNew onCreated={(secret) => setCreatedSecret(secret)} />;
case 'edit':
return <PluginEdit secret={createdSecret} />;
default:
return <PluginList />;
}
}, [formType, createdSecret]);
return (
<SettingRight
title={<SettingRightTitle title={title} onBack={formType ? onBack : undefined} />}
>
<div className="my-3 space-y-1">{FormPage}</div>
</SettingRight>
);
};

View File

@ -0,0 +1,32 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Textarea } from '@teable/ui-lib/shadcn';
import { useEffect, useState } from 'react';
export const JsonEditor = (props: {
value?: Record<string, any>;
onChange: (value: Record<string, any>) => void;
}) => {
const { value, onChange } = props;
const [text, setText] = useState('');
useEffect(() => {
setText(JSON.stringify(value, null, 2));
}, [value]);
const onBlur = () => {
try {
onChange(JSON.parse(text));
} catch (e: any) {
console.log(e.message);
}
};
return (
<Textarea
value={text}
onBlur={onBlur}
onChange={(e) => {
setText(e.target.value);
}}
/>
);
};

View File

@ -0,0 +1,100 @@
import { useMutation } from '@tanstack/react-query';
import { UploadType } from '@teable/openapi';
import { FileZone } from '@teable/sdk/components/FileZone';
import { Button, useToast } from '@teable/ui-lib/shadcn';
import Image from 'next/image';
import { useTranslation } from 'next-i18next';
import { useRef, useState } from 'react';
import { uploadFiles } from '@/features/app/utils/uploadFile';
import { settingPluginConfig } from '@/features/i18n/setting-plugin.config';
export const LogoEditor = (props: {
value?: string;
onChange: (value?: string | null) => void;
}) => {
const { value, onChange } = props;
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null);
const { t } = useTranslation(settingPluginConfig.i18nNamespaces);
const { toast } = useToast();
const fileInput = useRef<HTMLInputElement>(null);
const { mutateAsync: uploadLogo, isLoading: uploadLogoLoading } = useMutation({
mutationFn: (files: File[]) => uploadFiles(files, UploadType.Plugin),
onSuccess: (res) => {
if (res?.[0]) {
onChange(res[0].path);
setUploadedUrl(res[0].presignedUrl);
}
return res;
},
});
const logoChange = (files: File[]) => {
if (files.length === 0) return;
if (files.length > 1) {
toast({ title: t('plugin:form.logo.lengthError') });
return;
}
if (files[0].type.indexOf('image') === -1) {
toast({ title: t('plugin:form.logo.typeError') });
return;
}
uploadLogo(files);
};
return (
<div>
<div className="flex items-center gap-3">
<input
type="file"
className="hidden"
accept="image/*,"
ref={fileInput}
onChange={(e) => logoChange(Array.from(e.target.files || []))}
/>
<Button
type="button"
variant={'outline'}
size={'xs'}
className="m-1 gap-2 font-normal"
onClick={(e) => {
fileInput.current?.click();
e.stopPropagation();
e.preventDefault();
}}
>
{t('plugin:form.logo.upload')}
</Button>
{value && (
<Button type="button" size={'xs'} variant={'destructive'} onClick={() => onChange(null)}>
{t('plugin:form.logo.clear')}
</Button>
)}
</div>
<FileZone
className="size-52"
fileInputProps={{
accept: 'image/*,',
multiple: false,
}}
action={['click', 'drop']}
onChange={logoChange}
disabled={uploadLogoLoading}
defaultText={t('plugin:form.logo.placeholder')}
>
{value && (
<div className="relative size-full overflow-hidden rounded-md border border-border">
<Image
src={uploadedUrl || value}
alt="card cover"
fill
sizes="100%"
style={{
objectFit: 'contain',
}}
/>
</div>
)}
</FileZone>
</div>
);
};

View File

@ -0,0 +1,21 @@
import { Input } from '@teable/ui-lib/shadcn';
import { forwardRef } from 'react';
import { CopyButton } from '@/features/app/components/CopyButton';
export const NewSecret = forwardRef<HTMLDivElement, { secret?: string }>((props, ref) => {
const { secret } = props;
if (!secret) return;
return (
<div
ref={ref}
className="rounded border border-green-300 bg-green-300/20 p-3 text-sm dark:border-green-700 dark:bg-green-700/20"
>
<div className="flex items-center gap-3">
<Input className="h-8 w-[26rem] text-muted-foreground" readOnly value={secret} />
<CopyButton variant="outline" text={secret} size="xs" iconClassName="size-4" />
</div>
</div>
);
});
NewSecret.displayName = 'NewSecret';

View File

@ -0,0 +1,40 @@
import { PluginPosition } from '@teable/openapi';
import { Checkbox } from '@teable/ui-lib/shadcn';
import { useTranslation } from 'next-i18next';
import { useMemo } from 'react';
import { settingPluginConfig } from '@/features/i18n/setting-plugin.config';
export const PositionSelector = (props: {
value?: PluginPosition[];
onChange: (value?: PluginPosition[]) => void;
}) => {
const { value = [], onChange } = props;
const { t } = useTranslation(settingPluginConfig.i18nNamespaces);
const positionStatic = useMemo(() => {
return {
[PluginPosition.Dashboard]: t('common:noun.dashboard'),
};
}, [t]);
return (
<div>
{Object.values(PluginPosition).map((position) => (
<div key={position} className="flex items-center gap-2">
<Checkbox
id={`position-${position}`}
checked={value.includes(position)}
onCheckedChange={() => {
const newValue = value.includes(position)
? value.filter((v) => v !== position)
: [...value, position];
onChange(newValue);
}}
/>
<label htmlFor={`position-${position}`} className="text-sm font-normal">
{positionStatic[position]}
</label>
</div>
))}
</div>
);
};

View File

@ -0,0 +1,27 @@
import { useTheme } from '@teable/next-themes';
import { useEffect } from 'react';
import Markdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
export const MarkdownPreview = (props: { children?: string }) => {
const { resolvedTheme: currentTheme } = useTheme();
useEffect(() => {
if (currentTheme === 'dark') {
require('github-markdown-css/github-markdown-dark.css');
} else {
require('github-markdown-css/github-markdown-light.css');
}
}, [currentTheme]);
return (
<Markdown
className="markdown-body !bg-background px-3 py-2 !text-sm !text-foreground"
rehypePlugins={[rehypeRaw]}
remarkPlugins={[remarkGfm]}
>
{props.children}
</Markdown>
);
};

View File

@ -49,7 +49,7 @@ export const Sidebar: FC<PropsWithChildren<ISidebarProps>> = (props) => {
<HoverWrapper size={SIDE_BAR_WIDTH}>
<HoverWrapper.Trigger>
<Button
className={cn('absolute top-7 p-1 rounded-none -left-0 rounded-r-full z-[51]')}
className={cn('absolute top-7 p-1 rounded-none -left-0 rounded-r-full z-40')}
variant={'outline'}
size="xs"
onClick={() => {

View File

@ -32,7 +32,7 @@ export const HoverWrapper = (props: IHoverWrapperProps) => {
<div
className={cn(
'fixed flex h-full top-0 transition-[z-index] will-change-auto',
hover ? 'z-50 w-full' : 'w-auto z-0'
hover ? 'z-30 w-full' : 'w-auto z-0'
)}
>
<div

View File

@ -22,6 +22,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = (props) => {
src: avatar,
alt: name,
style: { objectFit: 'cover' },
quality: 100,
}).props;
return (

View File

@ -0,0 +1,103 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { IDashboardLayout } from '@teable/openapi';
import { getDashboard, updateLayoutDashboard } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useBaseId, useBasePermission } from '@teable/sdk/hooks';
import { cn } from '@teable/ui-lib/shadcn';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { PluginItem } from './components/PluginItem';
import { useIsExpandPlugin } from './hooks/useIsExpandPlugin';
const ResponsiveGridLayout = WidthProvider(Responsive);
export const DashboardGrid = (props: { dashboardId: string }) => {
const { dashboardId } = props;
const baseId = useBaseId()!;
const queryClient = useQueryClient();
const isExpandPlugin = useIsExpandPlugin();
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
const [isDragging, setIsDragging] = useState(false);
const basePermissions = useBasePermission();
const canMange = basePermissions?.['base|update'];
const { data: dashboardData } = useQuery({
queryKey: ReactQueryKeys.getDashboard(dashboardId),
queryFn: () => getDashboard(baseId, dashboardId).then((res) => res.data),
});
const { mutate: updateLayoutDashboardMutate } = useMutation({
mutationFn: (layout: IDashboardLayout) => updateLayoutDashboard(baseId, dashboardId, layout),
onSuccess: () => {
queryClient.invalidateQueries(ReactQueryKeys.getDashboard(dashboardId));
},
});
const layout = dashboardData?.layout ?? [];
const pluginMap = dashboardData?.pluginMap ?? {};
const onLayoutChange = (layout: ReactGridLayout.Layout[]) => {
updateLayoutDashboardMutate(
layout.map(({ i, x, y, w, h }) => ({ pluginInstallId: i, x, y, w, h }))
);
};
return (
<ResponsiveGridLayout
className="w-full"
layouts={{
md: layout.map(({ pluginInstallId, x, y, w, h }) => ({
i: pluginInstallId,
x,
y,
w,
h,
})),
}}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
rowHeight={80}
margin={[16, 16]}
containerPadding={[16, 16]}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
draggableHandle=".dashboard-draggable-handle"
onResize={() => setIsDragging(true)}
onResizeStop={(layout) => {
setIsDragging(false);
onLayoutChange(layout);
}}
onDrag={() => setIsDragging(true)}
onDragStop={(layout) => {
setIsDragging(false);
onLayoutChange(layout);
}}
isResizable={canMange}
isDraggable={canMange}
>
{layout.map(({ pluginInstallId, x, y, w, h }) => (
<div
key={pluginInstallId}
data-grid={{ x, y, w, h }}
className={cn({
'!transform-none !transition-none': isExpandPlugin(pluginInstallId),
})}
>
{pluginMap[pluginInstallId] ? (
<PluginItem
dragging={isDragging}
dashboardId={dashboardId}
name={pluginMap[pluginInstallId].name}
pluginId={pluginMap[pluginInstallId].id}
pluginUrl={pluginMap[pluginInstallId].url}
pluginInstallId={pluginMap[pluginInstallId].pluginInstallId}
/>
) : (
<div>{t('dashboard:pluginNotFound')}</div>
)}
</div>
))}
</ResponsiveGridLayout>
);
};

View File

@ -0,0 +1,120 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Edit, MoreHorizontal, Plus } from '@teable/icons';
import { deleteDashboard, getDashboardList, renameDashboard } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useBaseId, useBasePermission } from '@teable/sdk/hooks';
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
} from '@teable/ui-lib/shadcn';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { useRef, useState } from 'react';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { MenuDeleteItem } from '../components/MenuDeleteItem';
import { AddPluginDialog } from './components/AddPluginDialog';
import { DashboardSwitcher } from './components/DashboardSwitcher';
export const DashboardHeader = (props: { dashboardId: string }) => {
const { dashboardId } = props;
const baseId = useBaseId()!;
const router = useRouter();
const queryClient = useQueryClient();
const [menuOpen, setMenuOpen] = useState(false);
const [rename, setRename] = useState<string | null>(null);
const renameRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
const basePermissions = useBasePermission();
const canManage = basePermissions?.['base|update'];
const { mutate: deleteDashboardMutate } = useMutation({
mutationFn: () => deleteDashboard(baseId, dashboardId),
onSuccess: () => {
setMenuOpen(false);
queryClient.invalidateQueries(ReactQueryKeys.getDashboardList());
router.push(`/base/${baseId}/dashboard`);
},
});
const { data: dashboardList } = useQuery({
queryKey: ReactQueryKeys.getDashboardList(),
queryFn: () => getDashboardList(baseId).then((res) => res.data),
});
const { mutate: renameDashboardMutate } = useMutation({
mutationFn: () => renameDashboard(baseId, dashboardId, rename!),
onSuccess: () => {
setRename(null);
queryClient.invalidateQueries(ReactQueryKeys.getDashboardList());
},
});
const selectedDashboard = dashboardList?.find(({ id }) => id === dashboardId);
return (
<div className="flex h-16 shrink-0 items-center justify-between border-b px-4">
<DashboardSwitcher
className={cn('w-44', {
hidden: rename !== null,
})}
dashboardId={dashboardId}
onChange={(dashboardId) => {
router.push(`/base/${baseId}/dashboard?id=${dashboardId}`);
}}
/>
<Input
ref={renameRef}
className={cn('w-44', {
hidden: rename === null,
})}
value={rename ?? ''}
onBlur={() => {
if (!rename || selectedDashboard?.name === rename) {
setRename(null);
return;
}
renameDashboardMutate();
}}
onChange={(e) => setRename(e.target.value)}
/>
<div className="flex items-center gap-2">
{canManage && (
<AddPluginDialog dashboardId={dashboardId}>
<Button variant={'outline'} size={'xs'}>
<Plus />
{t('dashboard:addPlugin')}
</Button>
</AddPluginDialog>
)}
{canManage && (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline" className="size-6">
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="relative min-w-36 overflow-hidden">
<DropdownMenuItem
onSelect={() => {
setRename(selectedDashboard?.name ?? null);
setTimeout(() => renameRef.current?.focus(), 200);
}}
>
<Edit className="mr-1.5" />
{t('common:actions.rename')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<MenuDeleteItem onConfirm={deleteDashboardMutate} />
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,52 @@
import { useQuery } from '@tanstack/react-query';
import { Plus } from '@teable/icons';
import { getDashboard } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
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 { useTranslation } from 'next-i18next';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { AddPluginDialog } from './components/AddPluginDialog';
import { DashboardGrid } from './DashboardGrid';
export const DashboardMain = (props: { dashboardId: string }) => {
const { dashboardId } = props;
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
const baseId = useBaseId()!;
const basePermissions = useBasePermission();
const canManage = basePermissions?.['base|update'];
const { data: dashboardData, isLoading } = useQuery({
queryKey: ReactQueryKeys.getDashboard(dashboardId),
queryFn: () => getDashboard(baseId, dashboardId).then((res) => res.data),
});
if (isLoading) {
return (
<div className="flex flex-1 items-center justify-center">
<Spin />
</div>
);
}
if (isEmpty(dashboardData?.pluginMap) && !isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3">
<p>{t('dashboard:pluginEmpty.title')}</p>
{canManage && (
<AddPluginDialog dashboardId={dashboardId}>
<Button size={'xs'}>
<Plus />
{t('dashboard:addPlugin')}
</Button>
</AddPluginDialog>
)}
</div>
);
}
return (
<div className="flex-1 overflow-y-scroll p-4">
<DashboardGrid dashboardId={dashboardId} />
</div>
);
};

View File

@ -0,0 +1,23 @@
import { useBasePermission } from '@teable/sdk/hooks';
import { Button } from '@teable/ui-lib/shadcn';
import { useTranslation } from 'next-i18next';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { CreateDashboardDialog } from './components/CreateDashboardDialog';
export const EmptyDashboard = () => {
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
const basePermissions = useBasePermission();
const canManage = basePermissions?.['base|update'];
return (
<div className="flex h-full flex-col items-center justify-center gap-5 px-20">
<h1 className="text-2xl font-semibold">{t('dashboard:empty.title')}</h1>
<p className="text-center text-muted-foreground">{t('dashboard:empty.description')}</p>
{canManage && (
<CreateDashboardDialog>
<Button size={'xs'}>{t('dashboard:empty.create')}</Button>
</CreateDashboardDialog>
)}
</div>
);
};

View File

@ -1,76 +0,0 @@
import { StatisticsFunc } from '@teable/core';
import { ExpandRecorder } from '@teable/sdk/components';
import { useTableId, useViewId } from '@teable/sdk/hooks';
import { TabsContent, Card, CardContent, CardHeader, CardTitle } from '@teable/ui-lib/shadcn';
import { useState } from 'react';
import { GridViewBase } from '../blocks/view/grid/GridViewBase';
import { BarChartCard } from './components/BarChart';
import { LineChartCard } from './components/LineChart';
import { PieChartCard } from './components/PieChart';
import { useAggregates } from './hooks/useAggregates';
const test = [
StatisticsFunc.Average,
StatisticsFunc.Sum,
StatisticsFunc.Sum,
StatisticsFunc.Average,
];
export const GridContent: React.FC = () => {
const aggs = useAggregates(test);
const viewId = useViewId();
const tableId = useTableId();
const [expandRecordId, setExpandRecordId] = useState<string>();
return (
<TabsContent value="overview" className="grow space-y-4 px-8 ">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{aggs.map((agg, index) => (
<Card key={index}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{agg?.name}({agg?.func})
</CardTitle>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
className="size-4 text-muted-foreground"
>
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{agg?.value || '0'}</div>
<p className="text-xs text-muted-foreground">{agg?.func}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-8">
<BarChartCard className="col-span-4" />
<PieChartCard className="col-span-4" />
</div>
<div className="grid grid-cols-1">
<LineChartCard />
</div>
<div className="grid grid-cols-1">
<div className="h-[600px] w-full overflow-hidden rounded-xl border bg-card text-card-foreground shadow">
{viewId && <GridViewBase onRowExpand={setExpandRecordId} />}
{tableId && viewId && (
<ExpandRecorder
tableId={tableId}
recordId={expandRecordId}
recordIds={expandRecordId ? [expandRecordId] : []}
onClose={() => setExpandRecordId(undefined)}
/>
)}
</div>
</div>
</TabsContent>
);
};

View File

@ -1,38 +1,40 @@
import { Trans, useTranslation } from 'next-i18next';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { useQuery } from '@tanstack/react-query';
import { getDashboardList } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useBaseId } from '@teable/sdk/hooks';
import { Spin } from '@teable/ui-lib/base';
import { useRouter } from 'next/router';
import { useInitializationZodI18n } from '../hooks/useInitializationZodI18n';
import { DashboardHeader } from './DashboardHeader';
import { DashboardMain } from './DashboardMain';
import { EmptyDashboard } from './EmptyDashboard';
export function DashboardPage() {
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
return (
<div className="h-full flex-col md:flex">
<div className="flex h-full flex-1 flex-col gap-2 lg:gap-4">
<div className="items-center justify-between space-y-2 px-8 pb-2 pt-6 lg:flex">
<h2 className="text-3xl font-bold tracking-tight">{t('table:welcome.title')}</h2>
</div>
<div className="flex h-full flex-col items-center justify-center p-4">
<ul className="mb-4 space-y-2 text-left">
<li>{t('table:welcome.description')}</li>
<li>
<Trans
ns="table"
i18nKey="welcome.help"
components={{
HelpCenter: (
<a
href={t('help.mainLink')}
className="text-blue-500 hover:text-blue-700"
target="_blank"
rel="noreferrer"
>
{t('table:welcome.helpCenter')}
</a>
),
}}
></Trans>
</li>
</ul>
</div>
const baseId = useBaseId()!;
const router = useRouter();
useInitializationZodI18n();
const { data: dashboardList, isLoading } = useQuery({
queryKey: ReactQueryKeys.getDashboardList(),
queryFn: () => getDashboardList(baseId).then((res) => res.data),
enabled: !!baseId,
});
if (isLoading) {
return (
<div className="ml-4 mt-4">
<Spin />
</div>
);
}
if (!isLoading && !dashboardList?.length) {
return <EmptyDashboard />;
}
const dashboardId = (router.query.id as string) ?? dashboardList?.[0]?.id;
return (
<div className="flex h-full flex-col">
<DashboardHeader dashboardId={dashboardId} />
<DashboardMain dashboardId={dashboardId} />
</div>
);
}

View File

@ -0,0 +1,100 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
IDashboardInstallPluginRo,
IGetPluginCenterListVo,
IPluginI18n,
} from '@teable/openapi';
import { getPluginCenterList, installPlugin, PluginPosition } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useBaseId } from '@teable/sdk/hooks';
import { Dialog, DialogContent, DialogTrigger } from '@teable/ui-lib/shadcn';
import { get } from 'lodash';
import Image from 'next/image';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { PluginDetail } from './PluginDetail';
export const AddPluginDialog = (props: { children?: React.ReactNode; dashboardId: string }) => {
const { children, dashboardId } = props;
const baseId = useBaseId()!;
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { i18n } = useTranslation();
const language = i18n.language as unknown as keyof IPluginI18n;
const [detailPlugin, setDetailPlugin] = useState<IGetPluginCenterListVo[number]>();
const { data: plugins } = useQuery({
queryKey: ['plugin-center', PluginPosition.Dashboard] as const,
queryFn: ({ queryKey }) => getPluginCenterList([queryKey[1]]).then((res) => res.data),
});
const { mutate: installPluginMutate } = useMutation({
mutationFn: (ro: IDashboardInstallPluginRo) => installPlugin(baseId, dashboardId, ro),
onSuccess: () => {
setDetailPlugin(undefined);
setOpen(false);
queryClient.invalidateQueries(ReactQueryKeys.getDashboard(dashboardId));
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="max-w-4xl"
style={{ width: 'calc(100% - 40px)', height: 'calc(100% - 100px)' }}
>
<div className="mt-4 w-full space-y-3 md:grid md:grid-cols-2 md:gap-4 md:space-y-0">
{plugins?.map((plugin) => {
const name = get(plugin.i18n, [language, 'name']) ?? plugin.name;
const description = get(plugin.i18n, [language, 'description']) ?? plugin.description;
const detailDesc = get(plugin.i18n, [language, 'detailDesc']) ?? plugin.detailDesc;
return (
<button
key={plugin.id}
className="flex h-20 cursor-pointer items-center gap-3 rounded border p-2 hover:bg-accent"
onClick={() =>
setDetailPlugin({
...plugin,
name,
description,
detailDesc,
})
}
>
<Image
src={plugin.logo}
alt={name}
width={56}
height={56}
sizes="100%"
style={{
objectFit: 'contain',
}}
/>
<div className="flex-auto text-left">
<div>{name}</div>
<div
className="line-clamp-2 break-words text-[13px] text-muted-foreground"
title={description}
>
{description}
</div>
</div>
</button>
);
})}
</div>
{detailPlugin && (
<PluginDetail
plugin={detailPlugin}
onBack={() => setDetailPlugin(undefined)}
onInstall={() => {
installPluginMutate({ pluginId: detailPlugin.id, name: detailPlugin.name });
}}
/>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -1,35 +0,0 @@
import { Card, CardHeader, CardTitle, CardContent } from '@teable/ui-lib/shadcn';
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from 'recharts';
import { useChartData } from '../hooks/useChartData';
export function BarChartCard({ className }: { className?: string }) {
const data = useChartData();
return (
<Card className={className}>
<CardHeader>
<CardTitle>{data.title} (Bar)</CardTitle>
</CardHeader>
<CardContent className="pl-2">
<ResponsiveContainer width="100%" height={350}>
<BarChart data={data.list}>
<XAxis
dataKey="name"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `$${value}`}
/>
<Bar dataKey="total" fill="#adfa1d" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,104 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createDashboard, z } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useBaseId } from '@teable/sdk/hooks';
import { Error } from '@teable/ui-lib/base';
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
Input,
} from '@teable/ui-lib/shadcn';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { forwardRef, useImperativeHandle, useState } from 'react';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
interface ICreateDashboardDialogProps {
children?: React.ReactNode;
onSuccessCallback?: (dashboardId: string) => void;
}
export interface ICreateDashboardDialogRef {
open: () => void;
close: () => void;
}
export const CreateDashboardDialog = forwardRef<
ICreateDashboardDialogRef,
ICreateDashboardDialogProps
>(
(
props: { children?: React.ReactNode; onSuccessCallback?: (dashboardId: string) => void },
ref
) => {
const { onSuccessCallback } = props;
const baseId = useBaseId()!;
const router = useRouter();
const [error, setError] = useState<string>();
const [name, setName] = useState('');
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}));
const { mutate: createDashboardMutate } = useMutation({
mutationFn: (name: string) => createDashboard(baseId, { name }),
onSuccess: (res) => {
setOpen(false);
setName('');
queryClient.invalidateQueries(ReactQueryKeys.getDashboardList());
router.push(`/base/${baseId}/dashboard?id=${res.data.id}`);
if (onSuccessCallback) {
onSuccessCallback?.(res.data.id);
}
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>{t('dashboard:createDashboard.title')}</DialogHeader>
<div>
<Input
placeholder={t('dashboard:createDashboard.placeholder')}
value={name}
onChange={(e) => {
setError(undefined);
setName(e.target.value);
}}
/>
<Error error={error} />
</div>
<DialogFooter>
<Button
size={'sm'}
onClick={() => {
const valid = z
.string()
.min(1)
.safeParse(name || undefined);
if (!valid.success) {
setError(valid.error.errors?.[0].message);
return;
}
createDashboardMutate(name);
}}
>
{t('common:actions.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
);
CreateDashboardDialog.displayName = 'CreateDashboardDialog';

View File

@ -0,0 +1,114 @@
import { useQuery } from '@tanstack/react-query';
import { Check, ChevronsUpDown, PlusCircle } from '@teable/icons';
import { getDashboardList } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useBaseId, useBasePermission } from '@teable/sdk/hooks';
import {
Button,
cn,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
Popover,
PopoverContent,
PopoverTrigger,
} from '@teable/ui-lib/shadcn';
import { useTranslation } from 'next-i18next';
import { useRef, useState } from 'react';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import type { ICreateDashboardDialogRef } from './CreateDashboardDialog';
import { CreateDashboardDialog } from './CreateDashboardDialog';
export const DashboardSwitcher = (props: {
className?: string;
dashboardId: string;
onChange?: (dashboardId: string) => void;
}) => {
const { className, dashboardId } = props;
const [open, setOpen] = useState(false);
const baseId = useBaseId()!;
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
const createDashboardDialogRef = useRef<ICreateDashboardDialogRef>(null);
const { data: dashboardList } = useQuery({
queryKey: ReactQueryKeys.getDashboardList(),
queryFn: () => getDashboardList(baseId).then((res) => res.data),
});
const basePermissions = useBasePermission();
const canManage = basePermissions?.['base|update'];
const selectedDashboard = dashboardList?.find(({ id }) => id === dashboardId);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
size={'sm'}
aria-expanded={open}
aria-label="Select a team"
className={cn('w-[200px] justify-between', className)}
>
<span className="truncate">{selectedDashboard?.name}</span>
<ChevronsUpDown className="ml-auto size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={t('dashboard:findDashboard')} />
<CommandList>
<CommandEmpty>{t('common.search.empty')}</CommandEmpty>
<CommandGroup>
{dashboardList?.map(({ id, name }) => (
<CommandItem
key={id}
onSelect={() => {
if (id !== dashboardId) {
props.onChange?.(id);
setOpen(false);
}
}}
className="text-sm"
>
{name}
<Check
className={cn(
'ml-auto h-4 w-4',
dashboardId === id ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
{canManage && (
<>
<CommandSeparator />
<CommandList>
<CommandGroup>
<CreateDashboardDialog
ref={createDashboardDialogRef}
onSuccessCallback={() => setOpen(false)}
>
<CommandItem
onSelect={() => {
createDashboardDialogRef.current?.open();
}}
>
<PlusCircle className="mr-2 size-5" />
{t('dashboard:createDashboard.button')}
</CommandItem>
</CreateDashboardDialog>
</CommandGroup>
</CommandList>
</>
)}
</Command>
</PopoverContent>
</Popover>
);
};

View File

@ -1,88 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@teable/ui-lib/shadcn';
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
import { useLineChartData } from '../hooks/useLineChartData';
export function LineChartCard() {
const data = useLineChartData();
return (
<Card>
<CardHeader>
<CardTitle>{data.title} (Line)</CardTitle>
<CardDescription>Compare average and minimum values.</CardDescription>
</CardHeader>
<CardContent className="pb-4">
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data.list}
margin={{
top: 5,
right: 10,
left: 10,
bottom: 0,
}}
>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Average
</span>
<span className="font-bold text-muted-foreground">
{payload[0].value}
</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
Today
</span>
<span className="font-bold">{payload[1].value}</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Line
type="monotone"
strokeWidth={2}
dataKey="average"
activeDot={{
r: 6,
style: { fill: 'var(--theme-primary)', opacity: 0.25 },
}}
style={
{
stroke: 'hsl(var(--primary))',
opacity: 0.25,
} as React.CSSProperties
}
/>
<Line
type="monotone"
dataKey="total"
strokeWidth={2}
activeDot={{
r: 8,
style: { fill: 'var(--theme-primary)' },
}}
style={
{
stroke: 'hsl(var(--primary))',
} as React.CSSProperties
}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,74 +0,0 @@
import type { IFilter } from '@teable/core';
import { Filter as FilterIcon } from '@teable/icons';
import { ViewFilter } from '@teable/sdk/components';
import { useTable, useTables, useView, useViews } from '@teable/sdk/hooks';
import { Button, useToast } from '@teable/ui-lib/shadcn';
import { useCallback, useEffect } from 'react';
import z from 'zod';
import { fromZodError } from 'zod-validation-error';
import { Selector } from '@/components/Selector';
export const Pickers: React.FC<{
setAnchor(anchor: { tableId?: string; viewId?: string }): void;
}> = ({ setAnchor }) => {
const tables = useTables();
const table = useTable();
const views = useViews();
const view = useView();
const { toast } = useToast();
const onFilterChange = useCallback(
async (filters: IFilter | null) => {
await view?.updateFilter(filters).catch((e) => {
let message;
if (e instanceof z.ZodError) {
message = fromZodError(e).message;
} else {
message = e.message;
}
toast({
variant: 'destructive',
title: 'Uh oh! Something went wrong.',
description: message,
});
});
},
[toast, view]
);
useEffect(() => {
if (!table && tables[0]) {
setAnchor({ tableId: tables[0].id, viewId: tables[0].defaultViewId });
}
}, [setAnchor, table, tables]);
return (
<div className="flex items-center gap-2">
<Selector
selectedId={table?.id}
onChange={(tableId) => {
setAnchor({ tableId, viewId: tables.find(({ id }) => id === tableId)?.defaultViewId });
}}
candidates={tables}
placeholder="Select table..."
/>
<Selector
selectedId={view?.id}
onChange={(viewId) => {
setAnchor({ tableId: table?.id, viewId });
}}
candidates={views}
placeholder="Select view..."
/>
<ViewFilter filters={view?.filter as IFilter} onChange={onFilterChange}>
{(text) => (
<Button variant={'outline'} className={'font-normal'}>
<FilterIcon className="size-4 text-sm" />
<span className="truncate">{text}</span>
</Button>
)}
</ViewFilter>
</div>
);
};

View File

@ -1,63 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@teable/ui-lib/shadcn';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { useChartData } from '../hooks/useChartData';
export function PieChartCard({ className }: { className?: string }) {
const data = useChartData();
return (
<Card className={className}>
<CardHeader>
<CardTitle>{data.title} (Pie)</CardTitle>
<CardDescription>Your data distribution ratio.</CardDescription>
</CardHeader>
<CardContent className="pb-4">
<ResponsiveContainer width="100%" height={350}>
<PieChart
data={data.list}
margin={{
top: 5,
right: 10,
left: 10,
bottom: 0,
}}
>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{payload[0].name}
</span>
<span className="font-bold text-muted-foreground">
{payload[0].value}
</span>
</div>
</div>
</div>
);
}
return null;
}}
/>
<Pie
data={data.list}
dataKey="total"
nameKey="name"
stroke="hsl(var(--background))"
fill="hsl(var(--foreground))"
>
{data.list.map((entry, index) => (
<Cell key={`cell-${index}`} fill={data.list[index].color} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,88 @@
import { useTheme } from '@teable/next-themes';
import { useBaseId, useBasePermission } from '@teable/sdk/hooks';
import type { IChildBridgeMethods } from '@teable/sdk/plugin-bridge';
import { Spin } from '@teable/ui-lib/base';
import { cn } from '@teable/ui-lib/shadcn';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { useEffect, useMemo, useRef, useState } from 'react';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { PluginRender } from './PluginRender';
export const PluginContent = (props: {
className?: string;
pluginId: string;
pluginInstallId: string;
pluginUrl?: string;
dashboardId: string;
}) => {
const { className, pluginInstallId, pluginUrl, dashboardId, pluginId } = props;
const baseId = useBaseId()!;
const router = useRouter();
const expandPluginId = router.query.expandPluginId as string;
const {
t,
i18n: { resolvedLanguage },
} = useTranslation(dashboardConfig.i18nNamespaces);
const { resolvedTheme } = useTheme();
const [bridge, setBridge] = useState<IChildBridgeMethods>();
const basePermissions = useBasePermission();
const defaultTheme = useRef(resolvedTheme);
const iframeUrl = useMemo(() => {
if (!pluginUrl) {
return;
}
const url = new URL(pluginUrl);
url.searchParams.set('pluginInstallId', pluginInstallId);
url.searchParams.set('baseId', baseId);
url.searchParams.set('dashboardId', dashboardId);
url.searchParams.set('pluginId', pluginId);
defaultTheme.current && url.searchParams.set('theme', defaultTheme.current);
resolvedLanguage && url.searchParams.set('lang', resolvedLanguage);
return url.toString();
}, [pluginUrl, pluginInstallId, baseId, dashboardId, pluginId, resolvedLanguage]);
const canSetting = basePermissions?.['base|update'];
useEffect(() => {
bridge?.syncUIConfig({
isShowingSettings: expandPluginId === pluginInstallId && canSetting,
isExpand: expandPluginId === pluginInstallId,
theme: resolvedTheme,
});
}, [bridge, expandPluginId, pluginInstallId, resolvedTheme, canSetting]);
useEffect(() => {
if (!basePermissions) {
return;
}
bridge?.syncBasePermissions(basePermissions);
}, [basePermissions, bridge]);
if (!iframeUrl) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{t('dashboard:pluginUrlEmpty')}
</div>
);
}
return (
<div className={cn('relative size-full', className)}>
{!bridge && (
<div className="flex size-full items-center justify-center">
<Spin />
</div>
)}
<PluginRender
onBridge={setBridge}
src={iframeUrl}
{...{
pluginInstallId,
dashboardId,
baseId,
pluginId,
}}
/>
</div>
);
};

View File

@ -0,0 +1,79 @@
import { ArrowLeft } from '@teable/icons';
import type { IGetPluginCenterListVo } from '@teable/openapi';
import { useLanDayjs } from '@teable/sdk/hooks';
import { Button } from '@teable/ui-lib/shadcn';
import Image from 'next/image';
import { useTranslation } from 'next-i18next';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { MarkdownPreview } from '../../components/MarkdownPreview';
import { UserAvatar } from '../../components/user/UserAvatar';
export const PluginDetail = (props: {
plugin: IGetPluginCenterListVo[number];
onInstall?: () => void;
onBack?: () => void;
}) => {
const { plugin, onBack, onInstall } = props;
const dayjs = useLanDayjs();
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
return (
<div className="absolute left-0 top-0 flex size-full flex-col bg-background">
<Button className="ml-2 mt-2 w-20" variant={'ghost'} size={'sm'} onClick={onBack}>
<ArrowLeft />
{t('common:actions.back')}
</Button>
<div className="flex-1 gap-3 overflow-auto px-4 md:flex">
<div className="flex-1">
<div className="mb-4 flex h-20 items-center gap-3 p-2">
<Image
src={plugin.logo}
alt={plugin.name}
width={56}
height={56}
sizes="100%"
style={{
objectFit: 'contain',
}}
/>
<div className="flex-auto">
<div>{plugin.name}</div>
<div
className="line-clamp-2 break-words text-[13px] text-muted-foreground"
title={plugin.description}
>
{plugin.description}
</div>
</div>
</div>
<Button className="w-full md:hidden" size={'sm'} onClick={onInstall}>
{t('dashboard:install')}
</Button>
<div>
<MarkdownPreview>{plugin.detailDesc}</MarkdownPreview>
</div>
</div>
<div className="mb-4 w-1/4 space-y-4 text-sm">
<Button className="hidden w-full md:inline-block" size={'sm'} onClick={onInstall}>
{t('dashboard:install')}
</Button>
<div className="space-y-2">
<p>{t('dashboard:publisher')}</p>
<div className="flex items-center gap-2">
<UserAvatar
user={{
name: plugin.createdBy.name,
avatar: plugin.createdBy.avatar,
}}
/>
{plugin.createdBy.name}
</div>
</div>
<div className="space-y-1">
<p>{t('dashboard:lastUpdated')}</p>
<p className="text-xs">{dayjs(plugin.lastModifiedTime).fromNow()}</p>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,116 @@
import { DraggableHandle, Edit, Maximize2, MoreHorizontal, X } from '@teable/icons';
import { useBasePermission } from '@teable/sdk/hooks';
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
} from '@teable/ui-lib/shadcn';
import { useTranslation } from 'next-i18next';
import { useRef, useState } from 'react';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { MenuDeleteItem } from '../../components/MenuDeleteItem';
export const PluginHeader = (props: {
name: string;
isExpanded?: boolean;
onClose: () => void;
onDelete: () => void;
onExpand: () => void;
onNameChange: (name: string) => void;
}) => {
const { name, isExpanded, onClose, onDelete, onExpand, onNameChange } = props;
const [rename, setRename] = useState<string | null>(null);
const renameRef = useRef<HTMLInputElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
const basePermissions = useBasePermission();
if (isExpanded) {
return (
<div className="flex h-10 items-center border-b pl-4 pr-2">
<div className=" flex-1 truncate">{name}</div>
<Button variant={'ghost'} size={'xs'} onClick={onClose}>
<X className="size-4" />
</Button>
</div>
);
}
const canManage = basePermissions?.['base|update'];
return (
<div className="flex h-8 items-center gap-1 px-1">
<DraggableHandle
className={cn(
'dashboard-draggable-handle cursor-pointer opacity-0 group-hover:opacity-100',
{
'pointer-events-none !opacity-0': !canManage,
}
)}
/>
<div className="relative flex h-full flex-1 items-center overflow-hidden px-0.5">
<span className="truncate text-sm">{name}</span>
<Input
ref={renameRef}
style={{ width: 'calc(100% - 0.25rem)' }}
className={cn('absolute h-6 hidden', {
block: rename !== null,
})}
value={rename || ''}
onBlur={() => {
if (rename && rename !== name) {
onNameChange(rename);
}
setRename(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Escape') {
e.currentTarget.blur();
}
}}
onChange={(e) => setRename(e.target.value)}
/>
</div>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
className={cn('w-0 p-0 group-hover:w-auto group-hover:p-2', {
'w-auto px-2': menuOpen,
})}
variant={'ghost'}
size={'xs'}
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="relative min-w-36 overflow-hidden">
{canManage && (
<DropdownMenuItem
onSelect={() => {
setRename(name);
setTimeout(() => renameRef.current?.focus(), 200);
}}
>
<Edit className="mr-1.5" />
{t('common:actions.rename')}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={onExpand}>
<Maximize2 className="mr-1.5" />
{t('dashboard:expand')}
</DropdownMenuItem>
{canManage && (
<>
<DropdownMenuSeparator />
<MenuDeleteItem onConfirm={onDelete} />
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@ -0,0 +1,95 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { removePlugin, renamePlugin } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useBaseId } from '@teable/sdk/hooks';
import { cn } from '@teable/ui-lib/shadcn';
import { useRouter } from 'next/router';
import { useIsExpandPlugin } from '../hooks/useIsExpandPlugin';
import { PluginContent } from './PluginContent';
import { PluginHeader } from './PluginHeader';
export const PluginItem = (props: {
name: string;
pluginId: string;
dragging?: boolean;
pluginUrl?: string;
dashboardId: string;
pluginInstallId: string;
}) => {
const baseId = useBaseId()!;
const { pluginInstallId, dashboardId, dragging, pluginId, name, pluginUrl } = props;
const router = useRouter();
const queryClient = useQueryClient();
const isExpandPlugin = useIsExpandPlugin();
const { mutate: removePluginMutate } = useMutation({
mutationFn: () => removePlugin(baseId, dashboardId, pluginInstallId),
onSuccess: () => {
queryClient.invalidateQueries(ReactQueryKeys.getDashboard(dashboardId));
},
});
const { mutate: renamePluginMutate } = useMutation({
mutationFn: (name: string) => renamePlugin(baseId, dashboardId, pluginInstallId, name),
onSuccess: () => {
queryClient.invalidateQueries(ReactQueryKeys.getDashboard(dashboardId));
},
});
const onExpand = () => {
const query = { ...router.query, expandPluginId: pluginInstallId };
router.push({
pathname: router.pathname,
query,
});
};
const onClose = () => {
const query = { ...router.query };
delete query.expandPluginId;
router.push({
pathname: router.pathname,
query,
});
};
const isExpanded = isExpandPlugin(pluginInstallId);
return (
<div
className={cn('h-full', {
'fixed top-0 left-0 right-0 bottom-0 bg-black/20 flex items-center justify-center z-50':
isExpanded,
})}
onClick={onClose}
>
<div
className={cn(
'group flex h-full flex-col overflow-hidden rounded-xl border bg-background',
{
'md:w-4/5 h-4/5 w-full mx-4': isExpanded,
'pointer-events-none select-none': dragging,
}
)}
onClick={(e) => e.stopPropagation()}
>
<PluginHeader
name={name}
onDelete={removePluginMutate}
onNameChange={renamePluginMutate}
onExpand={onExpand}
onClose={onClose}
isExpanded={isExpanded}
/>
<PluginContent
pluginId={pluginId}
pluginInstallId={pluginInstallId}
pluginUrl={pluginUrl}
dashboardId={dashboardId}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,60 @@
import { pluginGetAuthCode, updateDashboardPluginStorage } from '@teable/openapi';
import type { IChildBridgeMethods, IParentBridgeMethods } from '@teable/sdk/plugin-bridge';
import type { Methods } from 'penpal';
import { connectToChild } from 'penpal';
import { useEffect, useRef } from 'react';
interface IPluginRenderProps extends React.IframeHTMLAttributes<HTMLIFrameElement> {
src: string;
pluginInstallId: string;
dashboardId: string;
pluginId: string;
baseId: string;
onBridge: (bridge?: IChildBridgeMethods) => void;
}
export const PluginRender = (props: IPluginRenderProps) => {
const { onBridge, pluginInstallId, baseId, dashboardId, pluginId, ...rest } = props;
const iframeRef = useRef<HTMLIFrameElement | null>(null);
useEffect(() => {
if (!iframeRef.current) {
return;
}
const methods: IParentBridgeMethods = {
expandRecord: (recordIds) => {
console.log('expandRecord', recordIds);
},
updateStorage: (storage) => {
return updateDashboardPluginStorage(baseId, dashboardId, pluginInstallId, storage).then(
(res) => res.data.storage ?? {}
);
},
getAuthCode: () => {
return pluginGetAuthCode(pluginId, baseId).then((res) => res.data);
},
};
const connection = connectToChild<IChildBridgeMethods>({
iframe: iframeRef.current,
timeout: 20000,
methods: methods as unknown as Methods,
});
connection.promise.then((child) => {
onBridge(child);
});
connection.promise.catch((error) => {
throw error;
});
connection;
return () => {
connection.destroy();
onBridge(undefined);
};
}, [onBridge, pluginInstallId, baseId, dashboardId, pluginId]);
// eslint-disable-next-line jsx-a11y/iframe-has-title
return <iframe {...rest} ref={iframeRef} className="size-full rounded-b p-1" />;
};

View File

@ -1,63 +0,0 @@
import { Avatar, AvatarFallback, AvatarImage } from '@teable/ui-lib/shadcn';
export function RecentSales() {
return (
<div className="space-y-8">
<div className="flex items-center">
<Avatar className="size-9">
<AvatarImage src="/avatars/01.png" alt="Avatar" />
<AvatarFallback>OM</AvatarFallback>
</Avatar>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">Olivia Martin</p>
<p className="text-sm text-muted-foreground">olivia.martin@email.com</p>
</div>
<div className="ml-auto font-medium">+$1,999.00</div>
</div>
<div className="flex items-center">
<Avatar className="flex size-9 items-center justify-center space-y-0 border">
<AvatarImage src="/avatars/02.png" alt="Avatar" />
<AvatarFallback>JL</AvatarFallback>
</Avatar>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">Jackson Lee</p>
<p className="text-sm text-muted-foreground">jackson.lee@email.com</p>
</div>
<div className="ml-auto font-medium">+$39.00</div>
</div>
<div className="flex items-center">
<Avatar className="size-9">
<AvatarImage src="/avatars/03.png" alt="Avatar" />
<AvatarFallback>IN</AvatarFallback>
</Avatar>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">Isabella Nguyen</p>
<p className="text-sm text-muted-foreground">isabella.nguyen@email.com</p>
</div>
<div className="ml-auto font-medium">+$299.00</div>
</div>
<div className="flex items-center">
<Avatar className="size-9">
<AvatarImage src="/avatars/04.png" alt="Avatar" />
<AvatarFallback>WK</AvatarFallback>
</Avatar>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">William Kim</p>
<p className="text-sm text-muted-foreground">will@email.com</p>
</div>
<div className="ml-auto font-medium">+$99.00</div>
</div>
<div className="flex items-center">
<Avatar className="size-9">
<AvatarImage src="/avatars/05.png" alt="Avatar" />
<AvatarFallback>SD</AvatarFallback>
</Avatar>
<div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">Sofia Davis</p>
<p className="text-sm text-muted-foreground">sofia.davis@email.com</p>
</div>
<div className="ml-auto font-medium">+$39.00</div>
</div>
</div>
);
}

View File

@ -1,9 +0,0 @@
import { Input } from '@teable/ui-lib/shadcn';
export function Search() {
return (
<div>
<Input type="search" placeholder="Search..." className="md:w-[100px] lg:w-[300px]" />
</div>
);
}

View File

@ -1,62 +0,0 @@
import type { StatisticsFunc } from '@teable/core';
import { CellValueType } from '@teable/core';
import { useFields, useTable, useViewId } from '@teable/sdk/hooks';
import { Table } from '@teable/sdk/model';
import { statisticsValue2DisplayValue } from '@teable/sdk/utils';
import { useEffect, useMemo, useState } from 'react';
export function useAggregates(funcs: StatisticsFunc[]) {
const fields = useFields();
const table = useTable();
const [aggregates, setAggregates] = useState<
({ value: string | null; name: string; func: string } | null)[]
>([]);
const viewId = useViewId();
const sortedFields = useMemo(
() => fields.filter((field) => field.cellValueType === CellValueType.Number),
[fields]
);
useEffect(() => {
if (!sortedFields.length || sortedFields[0].tableId !== table?.id) {
return;
}
const statsList = funcs.reduce(
(pre, cur, i) => {
const field = sortedFields[i];
if (!field || !table?.id || !viewId) {
return pre;
}
(pre[cur] = pre[cur] ?? []).push(field.id);
return pre;
},
{} as { [func in StatisticsFunc]: string[] }
);
if (!table?.id || !viewId || !Object.keys(statsList)?.length) {
return;
}
Table.getAggregations(table.id, {
viewId,
field: statsList,
}).then(({ data: { aggregations } }) => {
if (!aggregations) {
return;
}
setAggregates(
aggregations.map((aggregate, i) => {
const { total } = aggregate;
return {
name: sortedFields[i].name,
func: funcs[i],
value: statisticsValue2DisplayValue(funcs[i], total?.value || null, sortedFields[i]),
};
})
);
});
}, [funcs, sortedFields, table, viewId]);
return aggregates;
}

View File

@ -1,66 +0,0 @@
import type { ISelectFieldOptions } from '@teable/core';
import { Colors, ColorUtils, CellValueType, FieldType } from '@teable/core';
import { useBase, useFields, useTable, useView } from '@teable/sdk/hooks';
import { knex } from 'knex';
import { useEffect, useMemo, useState } from 'react';
import { useEnv } from '../../hooks/useEnv';
interface IData {
name: string;
color: string;
total: number;
}
export function useChartData() {
const fields = useFields();
const table = useTable();
const view = useView();
const base = useBase();
const { driver } = useEnv();
const [data, setData] = useState<{ list: IData[]; title: string }>({ title: '', list: [] });
const groupingField = useMemo(
() => fields.find((field) => field.type === FieldType.SingleSelect),
[fields]
);
const numberField = useMemo(
() =>
fields.find(
(field) => field.cellValueType === CellValueType.Number && !field.isMultipleCellValue
),
[fields]
);
useEffect(() => {
if (!base || !table || !groupingField || !numberField || !view || !driver) {
return;
}
if (table.id !== groupingField.tableId) {
return;
}
const nativeSql = knex({ client: driver })(table.dbTableName)
.select(`${groupingField.dbFieldName} as name`)
.sum(`${numberField.dbFieldName} as total`)
.groupBy(groupingField.dbFieldName)
.orderBy(groupingField.dbFieldName, 'desc')
.toString();
console.log('sqlQuery:', nativeSql);
base.sqlQuery(table.id, view.id, nativeSql).then((result) => {
console.log('sqlQuery:', result);
setData({
title: numberField.name,
list: (result.data as IData[]).map(({ total, name }) => ({
name: name || 'Untitled',
total: total || 0,
color: ColorUtils.getHexForColor(
(groupingField.options as ISelectFieldOptions).choices.find((c) => c.name === name)
?.color || Colors.TealLight1
),
})),
});
});
}, [base, driver, fields, groupingField, numberField, table, view]);
return data;
}

View File

@ -0,0 +1,11 @@
import { useRouter } from 'next/router';
import { useCallback } from 'react';
export const useIsExpandPlugin = () => {
const router = useRouter();
const expandPluginId = router.query.expandPluginId as string | undefined;
return useCallback(
(pluginInstallId: string) => expandPluginId === pluginInstallId,
[expandPluginId]
);
};

View File

@ -1,62 +0,0 @@
import { CellValueType, FieldType } from '@teable/core';
import { useBase, useFields, useTable, useViewId } from '@teable/sdk/hooks';
import { knex } from 'knex';
import { useEffect, useMemo, useState } from 'react';
import { useEnv } from '../../hooks/useEnv';
interface IData {
total: number;
average: number;
}
export function useLineChartData() {
const fields = useFields();
const base = useBase();
const table = useTable();
const viewId = useViewId();
const [data, setData] = useState<{ list: IData[]; title: string }>({ title: '', list: [] });
const { driver } = useEnv();
const selectField = useMemo(
() => fields.find((field) => field.type === FieldType.SingleSelect),
[fields]
);
const numberField = useMemo(
() =>
fields.find(
(field) => field.cellValueType === CellValueType.Number && !field.isMultipleCellValue
),
[fields]
);
useEffect(() => {
if (!base || !table || !selectField || !numberField || !viewId || !driver) {
return;
}
if (table.id !== selectField.tableId) {
return;
}
const nameColumn = selectField.dbFieldName;
const numberColumn = numberField.dbFieldName;
const nativeSql = knex({ client: driver })(table.dbTableName)
.select(nameColumn)
.min(numberColumn + ' as total')
.avg(numberColumn + ' as average')
.groupBy(nameColumn)
.toString();
console.log('useLineChartData:sqlQuery:', nativeSql);
base.sqlQuery(table.id, viewId, nativeSql).then((result) => {
console.log('useLineChartData:sqlQuery:', result);
setData({
title: numberField.name,
list: (result.data as IData[]).map(({ total, average }) => ({
total: total || 0,
average: average || 0,
})),
});
});
}, [fields, selectField, numberField, table, viewId, base, driver]);
return data;
}

View File

@ -26,6 +26,12 @@ export const useSettingRoute = () => {
route: '/setting/oauth-app',
pathTo: '/setting/oauth-app',
},
// {
// Icon: Code,
// label: t('setting:plugins'),
// route: '/setting/plugin',
// pathTo: '/setting/plugin',
// },
];
}, [t]);
};

View File

@ -1,9 +1,9 @@
import type { I18nActiveNamespaces } from '@/lib/i18n';
export interface IDashboardConfig {
i18nNamespaces: I18nActiveNamespaces<'common' | 'space' | 'sdk' | 'table'>;
i18nNamespaces: I18nActiveNamespaces<'common' | 'space' | 'sdk' | 'table' | 'dashboard' | 'zod'>;
}
export const dashboardConfig: IDashboardConfig = {
i18nNamespaces: ['common', 'space', 'sdk', 'table'],
i18nNamespaces: ['common', 'space', 'sdk', 'table', 'dashboard', 'zod'],
};

View File

@ -0,0 +1,9 @@
import type { I18nActiveNamespaces } from '@/lib/i18n';
export interface ISettingPluginConfig {
i18nNamespaces: I18nActiveNamespaces<'common' | 'sdk' | 'setting' | 'plugin' | 'zod'>;
}
export const settingPluginConfig: ISettingPluginConfig = {
i18nNamespaces: ['common', 'sdk', 'setting', 'plugin', 'zod'],
};

View File

@ -30,7 +30,6 @@ extendZodWithOpenApi(z);
* i.e.: import '@/assets/theme/style.scss'
*/
import '../styles/global.css';
import '../features/app/blocks/chart/chart-show/theme.css';
import '@fontsource-variable/inter';

View File

@ -1,9 +1,47 @@
import type { IGetBaseVo, ITableVo } from '@teable/openapi';
import type { GetServerSideProps } from 'next';
import { Trans, useTranslation } from 'next-i18next';
import type { ReactElement } from 'react';
import { BaseLayout } from '@/features/app/layouts/BaseLayout';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { getTranslationsProps } from '@/lib/i18n';
import type { NextPageWithLayout } from '@/lib/type';
import withAuthSSR from '@/lib/withAuthSSR';
const Node: NextPageWithLayout = () => {
return <p>redirecting</p>;
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
return (
<div className="h-full flex-col md:flex">
<div className="flex h-full flex-1 flex-col gap-2 lg:gap-4">
<div className="items-center justify-between space-y-2 px-8 pb-2 pt-6 lg:flex">
<h2 className="text-3xl font-bold tracking-tight">{t('table:welcome.title')}</h2>
</div>
<div className="flex h-full flex-col items-center justify-center p-4">
<ul className="mb-4 space-y-2 text-left">
<li>{t('table:welcome.description')}</li>
<li>
<Trans
ns="table"
i18nKey="welcome.help"
components={{
HelpCenter: (
<a
href={t('help.mainLink')}
className="text-blue-500 hover:text-blue-700"
target="_blank"
rel="noreferrer"
>
{t('table:welcome.helpCenter')}
</a>
),
}}
></Trans>
</li>
</ul>
</div>
</div>
</div>
);
};
export const getServerSideProps: GetServerSideProps = withAuthSSR(async (context, ssrApi) => {
@ -19,13 +57,22 @@ export const getServerSideProps: GetServerSideProps = withAuthSSR(async (context
},
};
}
const base = await ssrApi.getBaseById(baseId as string);
return {
redirect: {
destination: `/base/${baseId}/dashboard`,
permanent: false,
props: {
tableServerData: tables,
baseServerData: base,
...(await getTranslationsProps(context, ['common', 'sdk', 'table'])),
},
};
});
Node.getLayout = function getLayout(
page: ReactElement,
pageProps: { tableServerData: ITableVo[]; baseServerData: IGetBaseVo }
) {
return <BaseLayout {...pageProps}>{page}</BaseLayout>;
};
export default Node;

View File

@ -1,32 +0,0 @@
import type { IGetBaseVo, ITableVo } from '@teable/openapi';
import type { GetServerSideProps } from 'next';
import type { ReactElement } from 'react';
import { ChartLayout } from '@/features/app/blocks/chart/ChartLayout';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { getTranslationsProps } from '@/lib/i18n';
import type { NextPageWithLayout } from '@/lib/type';
import withAuthSSR from '@/lib/withAuthSSR';
import { ChartPage } from '../../features/app/blocks/chart/ChartPage';
const Node: NextPageWithLayout = () => <ChartPage />;
export const getServerSideProps: GetServerSideProps = withAuthSSR(async (context, ssrApi) => {
const { baseId } = context.query;
const result = await ssrApi.getTables(baseId as string);
const base = await ssrApi.getBaseById(baseId as string);
return {
props: {
tableServerData: result,
baseServerData: base,
...(await getTranslationsProps(context, dashboardConfig.i18nNamespaces)),
},
};
});
Node.getLayout = function getLayout(
page: ReactElement,
pageProps: { tableServerData: ITableVo[]; baseServerData: IGetBaseVo }
) {
return <ChartLayout {...pageProps}>{page}</ChartLayout>;
};
export default Node;

View File

@ -0,0 +1,24 @@
import type { GetServerSideProps } from 'next';
import type { ReactElement } from 'react';
import { PluginPage } from '@/features/app/blocks/setting/plugin/PluginPage';
import { SettingLayout } from '@/features/app/layouts/SettingLayout';
import { settingPluginConfig } from '@/features/i18n/setting-plugin.config';
import { getTranslationsProps } from '@/lib/i18n';
import type { NextPageWithLayout } from '@/lib/type';
const Plugin: NextPageWithLayout = () => {
return <PluginPage />;
};
export const getServerSideProps: GetServerSideProps = async (context) => {
return {
props: {
...(await getTranslationsProps(context, settingPluginConfig.i18nNamespaces)),
},
};
};
Plugin.getLayout = function getLayout(page: ReactElement, pageProps) {
return <SettingLayout {...pageProps}>{page}</SettingLayout>;
};
export default Plugin;

View File

@ -28,3 +28,10 @@ body {
min-height: calc(var(--child-height));
box-sizing: border-box;
}
.react-grid-item.react-grid-placeholder {
z-index: -1 !important;
border-radius: 0.75rem;
border-color: hsl(var(--primary)) !important;
background-color: hsl(var(--muted-foreground)) !important;
}

View File

@ -49,7 +49,7 @@ WORKDIR /app
COPY --from=deps --link /workspace-install ./
RUN set -ex; \
echo "NEXT_PUBLIC_BUILD_VERSION=\"${BUILD_VERSION}\"" >> apps/nextjs-app/.env; \
echo "\nNEXT_PUBLIC_BUILD_VERSION=\"${BUILD_VERSION}\"" >> apps/nextjs-app/.env; \
# Distinguish whether it is an integration test operation
if [ -n "$INTEGRATION_TEST" ]; then \
pnpm -F "./packages/**" run build; \
@ -159,6 +159,11 @@ COPY --from=post-builder --chown=nodejs:nodejs /app/packages ./packages
COPY --from=post-builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=post-builder --chown=nodejs:nodejs /app/package.json ./package.json
COPY --from=post-builder --chown=nodejs:nodejs /app/plugins/chart/.next/standalone/plugins ./plugins
COPY --from=post-builder --chown=nodejs:nodejs /app/apps/nestjs-backend/static ./static
COPY --chown=nodejs:nodejs scripts/start.sh ./scripts/start.sh
EXPOSE ${PORT}
ENTRYPOINT ["node", "apps/nestjs-backend/dist/index.js"]
ENTRYPOINT ["scripts/start.sh"]

View File

@ -19,6 +19,7 @@
"workspaces": [
"apps/*",
"packages/*",
"plugins/*",
"!apps/electron"
],
"scripts": {

View File

@ -1,7 +1,9 @@
import type auth from './locales/en/auth.json';
import type common from './locales/en/common.json';
import type dashboard from './locales/en/dashboard.json';
import type developer from './locales/en/developer.json';
import type oauth from './locales/en/oauth.json';
import type plugin from './locales/en/plugin.json';
import type sdk from './locales/en/sdk.json';
import type setting from './locales/en/setting.json';
import type share from './locales/en/share.json';
@ -24,4 +26,6 @@ export interface I18nNamespaces {
oauth: typeof oauth;
zod: typeof zod;
developer: typeof developer;
plugin: typeof plugin;
dashboard: typeof dashboard;
}

View File

@ -0,0 +1,23 @@
{
"empty": {
"title": "No Dashboards Yet",
"description": "It looks like you havent created any dashboards yet. Dashboards help you visualize and manage your data more effectively.",
"create": "Create Your First Dashboard"
},
"addPlugin": "Add an Plugin",
"createDashboard": {
"button": "Create Dashboard",
"title": "Create New Dashboard",
"placeholder": "Enter dashboard name"
},
"findDashboard": "Find a dashboard...",
"pluginUrlEmpty": "Plugin Not Setting url",
"install": "Install",
"publisher": "Publisher",
"lastUpdated": "Last Updated",
"expand": "Expand",
"pluginNotFound": "Plugin Not Found",
"pluginEmpty": {
"title": "No Plugins Yet"
}
}

View File

@ -0,0 +1,56 @@
{
"add": "New Plugins",
"title": {
"add": "New Plugins",
"edit": "Edit Plugins"
},
"pluginUser": {
"name": "Plugin User",
"description": "Plugin User as a plugin auto generated by the system"
},
"secret": "Secret",
"regenerateSecret": "Regenerate Secret",
"form": {
"name": {
"label": "Name",
"description": "name of the plugin"
},
"description": {
"label": "Description",
"description": "description of the plugin"
},
"detailDesc": {
"label": "Detail Description",
"description": "detail description of the plugin"
},
"logo": {
"label": "Logo",
"description": "logo of the plugin, you can upload a picture or use a URL",
"upload": "Upload",
"clear": "Clear",
"placeholder": "Drag and drop your logo here or click to upload",
"lengthError": "Only one file is allowed.",
"typeError": "Only image file is allowed."
},
"helpUrl": {
"label": "Help Url",
"description": "URL of the help document of the plugin"
},
"positions": {
"label": "Positions",
"description": "positions of the plugin"
},
"i18n": {
"label": "I18n",
"description": "i18n of the plugin, contains(name, description, detail description)"
},
"url": {
"label": "Url",
"description": "URL of the plugin"
}
},
"markdown": {
"write": "Write",
"preview": "Preview"
}
}

Some files were not shown because too many files have changed in this diff Show More