mirror of
https://github.com/teableio/teable
synced 2024-11-21 14:51:09 +00:00
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:
parent
a184b5a47b
commit
a3171aedfc
@ -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",
|
||||
|
@ -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],
|
||||
};
|
||||
|
5
apps/nestjs-backend/src/cache/types.ts
vendored
5
apps/nestjs-backend/src/cache/types.ts
vendored
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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');
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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],
|
||||
})
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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();
|
||||
});
|
||||
});
|
363
apps/nestjs-backend/src/features/dashboard/dashboard.service.ts
Normal file
363
apps/nestjs-backend/src/features/dashboard/dashboard.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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],
|
||||
})
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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/?*',
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PluginProxyModule } from './plugin-proxy.module';
|
||||
@Module({
|
||||
imports: [PluginProxyModule],
|
||||
providers: [],
|
||||
controllers: [],
|
||||
})
|
||||
export class NextPluginModule {}
|
@ -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',
|
||||
};
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
191
apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts
Normal file
191
apps/nestjs-backend/src/features/plugin/plugin-auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
109
apps/nestjs-backend/src/features/plugin/plugin.controller.ts
Normal file
109
apps/nestjs-backend/src/features/plugin/plugin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
30
apps/nestjs-backend/src/features/plugin/plugin.module.ts
Normal file
30
apps/nestjs-backend/src/features/plugin/plugin.module.ts
Normal 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 {}
|
@ -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();
|
||||
});
|
||||
});
|
361
apps/nestjs-backend/src/features/plugin/plugin.service.ts
Normal file
361
apps/nestjs-backend/src/features/plugin/plugin.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
15
apps/nestjs-backend/src/features/plugin/utils.ts
Normal file
15
apps/nestjs-backend/src/features/plugin/utils.ts
Normal 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);
|
||||
};
|
14
apps/nestjs-backend/src/features/setting/admin.controller.ts
Normal file
14
apps/nestjs-backend/src/features/setting/admin.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
15
apps/nestjs-backend/src/features/setting/admin.service.ts
Normal file
15
apps/nestjs-backend/src/features/setting/admin.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
@ -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 {}
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
BIN
apps/nestjs-backend/static/plugin/chart.png
Normal file
BIN
apps/nestjs-backend/static/plugin/chart.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
|
||||
|
157
apps/nestjs-backend/test/dashboard.e2e-spec.ts
Normal file
157
apps/nestjs-backend/test/dashboard.e2e-spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
|
146
apps/nestjs-backend/test/plugin.e2e-spec.ts
Normal file
146
apps/nestjs-backend/test/plugin.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -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",
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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';
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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={() => {
|
||||
|
@ -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
|
||||
|
@ -22,6 +22,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = (props) => {
|
||||
src: avatar,
|
||||
alt: name,
|
||||
style: { objectFit: 'cover' },
|
||||
quality: 100,
|
||||
}).props;
|
||||
|
||||
return (
|
||||
|
103
apps/nextjs-app/src/features/app/dashboard/DashboardGrid.tsx
Normal file
103
apps/nextjs-app/src/features/app/dashboard/DashboardGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
120
apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx
Normal file
120
apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
52
apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx
Normal file
52
apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
@ -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';
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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" />;
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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]
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
@ -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]);
|
||||
};
|
||||
|
@ -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'],
|
||||
};
|
||||
|
@ -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'],
|
||||
};
|
@ -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';
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
24
apps/nextjs-app/src/pages/setting/plugin.tsx
Normal file
24
apps/nextjs-app/src/pages/setting/plugin.tsx
Normal 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;
|
@ -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;
|
||||
}
|
||||
|
@ -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"]
|
||||
|
@ -19,6 +19,7 @@
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*",
|
||||
"plugins/*",
|
||||
"!apps/electron"
|
||||
],
|
||||
"scripts": {
|
||||
|
@ -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;
|
||||
}
|
||||
|
23
packages/common-i18n/src/locales/en/dashboard.json
Normal file
23
packages/common-i18n/src/locales/en/dashboard.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"empty": {
|
||||
"title": "No Dashboards Yet",
|
||||
"description": "It looks like you haven’t 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"
|
||||
}
|
||||
}
|
56
packages/common-i18n/src/locales/en/plugin.json
Normal file
56
packages/common-i18n/src/locales/en/plugin.json
Normal 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
Loading…
Reference in New Issue
Block a user