diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts
index 090e9b3991..77be6a3e77 100644
--- a/apps/nestjs-backend/src/app.module.ts
+++ b/apps/nestjs-backend/src/app.module.ts
@@ -12,6 +12,7 @@ import { AiModule } from './features/ai/ai.module';
import { AttachmentsModule } from './features/attachments/attachments.module';
import { AuthModule } from './features/auth/auth.module';
import { BaseModule } from './features/base/base.module';
+import { BaseNodeModule } from './features/base-node/base-node.module';
import { ChatModule } from './features/chat/chat.module';
import { CollaboratorModule } from './features/collaborator/collaborator.module';
import { CommentOpenApiModule } from './features/comment/comment-open-api.module';
@@ -59,6 +60,7 @@ export const appModules = {
FieldOpenApiModule,
TemplateOpenApiModule,
BaseModule,
+ BaseNodeModule,
IntegrityModule,
ChatModule,
AttachmentsModule,
diff --git a/apps/nestjs-backend/src/event-emitter/events/app/app.event.ts b/apps/nestjs-backend/src/event-emitter/events/app/app.event.ts
new file mode 100644
index 0000000000..a934174e5d
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/app/app.event.ts
@@ -0,0 +1,59 @@
+import { match } from 'ts-pattern';
+import type { IEventContext } from '../core-event';
+import { CoreEvent } from '../core-event';
+import { Events } from '../event.enum';
+
+interface IAppVo {
+ id: string;
+ name: string;
+}
+
+type IAppCreatePayload = { baseId: string; app: IAppVo };
+type IAppDeletePayload = { baseId: string; appId: string };
+type IAppUpdatePayload = { baseId: string; app: IAppVo };
+
+export class AppCreateEvent extends CoreEvent {
+ public readonly name = Events.APP_CREATE;
+
+ constructor(baseId: string, app: IAppVo, context: IEventContext) {
+ super({ baseId, app }, context);
+ }
+}
+
+export class AppDeleteEvent extends CoreEvent {
+ public readonly name = Events.APP_DELETE;
+ constructor(baseId: string, appId: string, context: IEventContext) {
+ super({ baseId, appId }, context);
+ }
+}
+
+export class AppUpdateEvent extends CoreEvent {
+ public readonly name = Events.APP_UPDATE;
+
+ constructor(baseId: string, app: IAppVo, context: IEventContext) {
+ super({ baseId, app }, context);
+ }
+}
+
+export class AppEventFactory {
+ static create(
+ name: string,
+ payload: IAppCreatePayload | IAppDeletePayload | IAppUpdatePayload,
+ context: IEventContext
+ ) {
+ return match(name)
+ .with(Events.APP_CREATE, () => {
+ const { baseId, app } = payload as IAppCreatePayload;
+ return new AppCreateEvent(baseId, app, context);
+ })
+ .with(Events.APP_UPDATE, () => {
+ const { baseId, app } = payload as IAppUpdatePayload;
+ return new AppUpdateEvent(baseId, app, context);
+ })
+ .with(Events.APP_DELETE, () => {
+ const { baseId, appId } = payload as IAppDeletePayload;
+ return new AppDeleteEvent(baseId, appId, context);
+ })
+ .otherwise(() => null);
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/base/folder/base.folder.event.ts b/apps/nestjs-backend/src/event-emitter/events/base/folder/base.folder.event.ts
new file mode 100644
index 0000000000..a30b0cc58a
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/base/folder/base.folder.event.ts
@@ -0,0 +1,59 @@
+import { match } from 'ts-pattern';
+import type { IEventContext } from '../../core-event';
+import { CoreEvent } from '../../core-event';
+import { Events } from '../../event.enum';
+
+type IBaseFolder = {
+ id: string;
+ name: string;
+};
+
+type IBaseFolderCreatePayload = { baseId: string; folder: IBaseFolder };
+type IBaseFolderDeletePayload = { baseId: string; folderId: string };
+type IBaseFolderUpdatePayload = IBaseFolderCreatePayload;
+
+export class BaseFolderCreateEvent extends CoreEvent {
+ public readonly name = Events.BASE_FOLDER_CREATE;
+
+ constructor(payload: IBaseFolderCreatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class BaseFolderDeleteEvent extends CoreEvent {
+ public readonly name = Events.BASE_FOLDER_DELETE;
+ constructor(payload: IBaseFolderDeletePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class BaseFolderUpdateEvent extends CoreEvent {
+ public readonly name = Events.BASE_FOLDER_UPDATE;
+
+ constructor(payload: IBaseFolderUpdatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class BaseFolderEventFactory {
+ static create(
+ name: string,
+ payload: IBaseFolderCreatePayload | IBaseFolderDeletePayload | IBaseFolderUpdatePayload,
+ context: IEventContext
+ ) {
+ return match(name)
+ .with(Events.BASE_FOLDER_CREATE, () => {
+ const { baseId, folder } = payload as IBaseFolderCreatePayload;
+ return new BaseFolderCreateEvent({ baseId, folder }, context);
+ })
+ .with(Events.BASE_FOLDER_DELETE, () => {
+ const { baseId, folderId } = payload as IBaseFolderDeletePayload;
+ return new BaseFolderDeleteEvent({ baseId, folderId }, context);
+ })
+ .with(Events.BASE_FOLDER_UPDATE, () => {
+ const { baseId, folder } = payload as IBaseFolderUpdatePayload;
+ return new BaseFolderUpdateEvent({ baseId, folder }, context);
+ })
+ .otherwise(() => null);
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/dashboard/dashboard.event.ts b/apps/nestjs-backend/src/event-emitter/events/dashboard/dashboard.event.ts
new file mode 100644
index 0000000000..71ef2c2eb7
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/dashboard/dashboard.event.ts
@@ -0,0 +1,55 @@
+import type { ICreateDashboardVo } from '@teable/openapi';
+import { match } from 'ts-pattern';
+import type { IEventContext } from '../core-event';
+import { CoreEvent } from '../core-event';
+import { Events } from '../event.enum';
+
+type IDashboardCreatePayload = { baseId: string; dashboard: ICreateDashboardVo };
+type IDashboardUpdatePayload = { baseId: string; dashboard: ICreateDashboardVo };
+type IDashboardDeletePayload = { baseId: string; dashboardId: string };
+
+export class DashboardCreateEvent extends CoreEvent {
+ public readonly name = Events.DASHBOARD_CREATE;
+
+ constructor(payload: IDashboardCreatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class DashboardDeleteEvent extends CoreEvent {
+ public readonly name = Events.DASHBOARD_DELETE;
+ constructor(payload: IDashboardDeletePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class DashboardUpdateEvent extends CoreEvent {
+ public readonly name = Events.DASHBOARD_UPDATE;
+
+ constructor(payload: IDashboardUpdatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class DashboardEventFactory {
+ static create(
+ name: string,
+ payload: IDashboardCreatePayload | IDashboardDeletePayload | IDashboardUpdatePayload,
+ context: IEventContext
+ ) {
+ return match(name)
+ .with(Events.DASHBOARD_CREATE, () => {
+ const { baseId, dashboard } = payload as IDashboardCreatePayload;
+ return new DashboardCreateEvent({ baseId, dashboard }, context);
+ })
+ .with(Events.DASHBOARD_DELETE, () => {
+ const { baseId, dashboardId } = payload as IDashboardDeletePayload;
+ return new DashboardDeleteEvent({ baseId, dashboardId }, context);
+ })
+ .with(Events.DASHBOARD_UPDATE, () => {
+ const { baseId, dashboard } = payload as IDashboardUpdatePayload;
+ return new DashboardUpdateEvent({ baseId, dashboard }, context);
+ })
+ .otherwise(() => null);
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/event.enum.ts b/apps/nestjs-backend/src/event-emitter/events/event.enum.ts
index ffc13c7448..861c9cfe20 100644
--- a/apps/nestjs-backend/src/event-emitter/events/event.enum.ts
+++ b/apps/nestjs-backend/src/event-emitter/events/event.enum.ts
@@ -62,9 +62,24 @@ export enum Events {
COLLABORATOR_CREATE = 'collaborator.create',
COLLABORATOR_DELETE = 'collaborator.delete',
+ BASE_FOLDER_CREATE = 'base.folder.create',
+ BASE_FOLDER_DELETE = 'base.folder.delete',
+ BASE_FOLDER_UPDATE = 'base.folder.update',
+
+ DASHBOARD_CREATE = 'dashboard.create',
+ DASHBOARD_DELETE = 'dashboard.delete',
+ DASHBOARD_UPDATE = 'dashboard.update',
+
+ WORKFLOW_CREATE = 'workflow.create',
+ WORKFLOW_DELETE = 'workflow.delete',
+ WORKFLOW_UPDATE = 'workflow.update',
WORKFLOW_ACTIVATE = 'workflow.activate',
WORKFLOW_DEACTIVATE = 'workflow.deactivate',
+ APP_CREATE = 'app.create',
+ APP_DELETE = 'app.delete',
+ APP_UPDATE = 'app.update',
+
CROP_IMAGE = 'crop.image',
CROP_IMAGE_COMPLETE = 'crop.image.complete',
diff --git a/apps/nestjs-backend/src/event-emitter/events/index.ts b/apps/nestjs-backend/src/event-emitter/events/index.ts
index a5b75d18cf..ba9e007efd 100644
--- a/apps/nestjs-backend/src/event-emitter/events/index.ts
+++ b/apps/nestjs-backend/src/event-emitter/events/index.ts
@@ -2,6 +2,10 @@ export * from './event.enum';
export * from './core-event';
export * from './op-event';
export * from './base/base.event';
+export * from './base/folder/base.folder.event';
export * from './space/space.event';
export * from './space/collaborator.event';
export * from './table';
+export * from './dashboard/dashboard.event';
+export * from './workflow/workflow.event';
+export * from './app/app.event';
diff --git a/apps/nestjs-backend/src/event-emitter/events/workflow/workflow.event.ts b/apps/nestjs-backend/src/event-emitter/events/workflow/workflow.event.ts
new file mode 100644
index 0000000000..99a09b7c6d
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/workflow/workflow.event.ts
@@ -0,0 +1,59 @@
+import { match } from 'ts-pattern';
+import type { IEventContext } from '../core-event';
+import { CoreEvent } from '../core-event';
+import { Events } from '../event.enum';
+
+interface IWorkflowVo {
+ id: string;
+ name: string;
+}
+
+type IWorkflowCreatePayload = { baseId: string; workflow: IWorkflowVo };
+type IWorkflowDeletePayload = { baseId: string; workflowId: string };
+type IWorkflowUpdatePayload = IWorkflowCreatePayload;
+
+export class WorkflowCreateEvent extends CoreEvent {
+ public readonly name = Events.WORKFLOW_CREATE;
+
+ constructor(payload: IWorkflowCreatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class WorkflowDeleteEvent extends CoreEvent {
+ public readonly name = Events.WORKFLOW_DELETE;
+ constructor(payload: IWorkflowDeletePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class WorkflowUpdateEvent extends CoreEvent {
+ public readonly name = Events.WORKFLOW_UPDATE;
+
+ constructor(payload: IWorkflowUpdatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class WorkflowEventFactory {
+ static create(
+ name: string,
+ payload: IWorkflowCreatePayload | IWorkflowDeletePayload | IWorkflowUpdatePayload,
+ context: IEventContext
+ ) {
+ return match(name)
+ .with(Events.WORKFLOW_CREATE, () => {
+ const { baseId, workflow } = payload as IWorkflowCreatePayload;
+ return new WorkflowCreateEvent({ baseId, workflow }, context);
+ })
+ .with(Events.WORKFLOW_DELETE, () => {
+ const { baseId, workflowId } = payload as IWorkflowDeletePayload;
+ return new WorkflowDeleteEvent({ baseId, workflowId }, context);
+ })
+ .with(Events.WORKFLOW_UPDATE, () => {
+ const { baseId, workflow } = payload as IWorkflowUpdatePayload;
+ return new WorkflowUpdateEvent({ baseId, workflow }, context);
+ })
+ .otherwise(() => null);
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts b/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts
index 6014037070..c80e99bb7e 100644
--- a/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts
+++ b/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts
@@ -9,7 +9,14 @@ import { match, P } from 'ts-pattern';
import { EMIT_EVENT_NAME } from '../decorators/emit-controller-event.decorator';
import { EventEmitterService } from '../event-emitter.service';
import type { IEventContext } from '../events';
-import { Events, BaseEventFactory, SpaceEventFactory } from '../events';
+import {
+ Events,
+ BaseEventFactory,
+ SpaceEventFactory,
+ DashboardEventFactory,
+ AppEventFactory,
+ WorkflowEventFactory,
+} from '../events';
@Injectable()
export class EventMiddleware implements NestInterceptor {
@@ -69,6 +76,36 @@ export class EventMiddleware implements NestInterceptor {
.with(P.union(Events.SPACE_CREATE, Events.SPACE_UPDATE), () =>
SpaceEventFactory.create(eventName, { space: resolveData, ...reqParams }, eventContext)
)
+ .with(Events.WORKFLOW_DELETE, () =>
+ WorkflowEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)
+ )
+ .with(P.union(Events.WORKFLOW_CREATE, Events.WORKFLOW_UPDATE), () =>
+ WorkflowEventFactory.create(
+ eventName,
+ { baseId: reqParams.baseId, workflow: resolveData, ...reqParams },
+ eventContext
+ )
+ )
+ .with(Events.APP_DELETE, () =>
+ AppEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)
+ )
+ .with(P.union(Events.APP_CREATE, Events.APP_UPDATE), () =>
+ AppEventFactory.create(
+ eventName,
+ { baseId: reqParams.baseId, app: resolveData, ...reqParams },
+ eventContext
+ )
+ )
+ .with(Events.DASHBOARD_DELETE, () =>
+ DashboardEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)
+ )
+ .with(P.union(Events.DASHBOARD_CREATE, Events.DASHBOARD_UPDATE), () =>
+ DashboardEventFactory.create(
+ eventName,
+ { baseId: reqParams.baseId, dashboard: resolveData, ...reqParams },
+ eventContext
+ )
+ )
.otherwise(() => null);
}
}
diff --git a/apps/nestjs-backend/src/features/auth/decorators/base-node-permissions.decorator.ts b/apps/nestjs-backend/src/features/auth/decorators/base-node-permissions.decorator.ts
new file mode 100644
index 0000000000..2abe54d129
--- /dev/null
+++ b/apps/nestjs-backend/src/features/auth/decorators/base-node-permissions.decorator.ts
@@ -0,0 +1,8 @@
+import { SetMetadata } from '@nestjs/common';
+import type { BaseNodeAction } from '@teable/core';
+
+export const BASE_NODE_PERMISSIONS_KEY = 'baseNodePermissions';
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const BaseNodePermissions = (...permissions: BaseNodeAction[]) =>
+ SetMetadata(BASE_NODE_PERMISSIONS_KEY, permissions);
diff --git a/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts b/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts
new file mode 100644
index 0000000000..7af1ac67c6
--- /dev/null
+++ b/apps/nestjs-backend/src/features/auth/guard/base-node-permission.guard.ts
@@ -0,0 +1,145 @@
+import type { ExecutionContext } from '@nestjs/common';
+import { Injectable } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { HttpErrorCode } from '@teable/core';
+import type { BaseNodeAction } from '@teable/core';
+import { PrismaService } from '@teable/db-main-prisma';
+import type { BaseNodeResourceType } from '@teable/openapi';
+import { ClsService } from 'nestjs-cls';
+import { CustomHttpException } from '../../../custom.exception';
+import type { IClsStore } from '../../../types/cls';
+import {
+ checkBaseNodePermission,
+ checkBaseNodePermissionCreate,
+} from '../../base-node/base-node.permission.helper';
+import { BASE_NODE_PERMISSIONS_KEY } from '../decorators/base-node-permissions.decorator';
+import { IS_DISABLED_PERMISSION } from '../decorators/disabled-permission.decorator';
+import { PermissionService } from '../permission.service';
+import { PermissionGuard } from './permission.guard';
+
+@Injectable()
+export class BaseNodePermissionGuard extends PermissionGuard {
+ constructor(
+ private readonly reflectorInner: Reflector,
+ private readonly clsInner: ClsService,
+ private readonly permissionServiceInner: PermissionService,
+ private readonly prismaService: PrismaService
+ ) {
+ super(reflectorInner, clsInner, permissionServiceInner);
+ }
+
+ async canActivate(context: ExecutionContext) {
+ const superResult = await super.canActivate(context);
+ if (!superResult) {
+ return false;
+ }
+
+ // disabled check
+ const isDisabledPermission = this.reflectorInner.getAllAndOverride(
+ IS_DISABLED_PERMISSION,
+ [context.getHandler(), context.getClass()]
+ );
+
+ if (isDisabledPermission) {
+ return true;
+ }
+
+ const baseId = this.getBaseId(context);
+ if (!baseId) {
+ throw new CustomHttpException('Base ID is required', HttpErrorCode.RESTRICTED_RESOURCE);
+ }
+ const permissionContext = await this.getPermissionContext();
+ return this.checkActivate(context, baseId, permissionContext);
+ }
+
+ async checkActivate(
+ context: ExecutionContext,
+ baseId: string,
+ permissionContext: {
+ permissionSet: Set;
+ tablePermissionMap?: Record;
+ }
+ ) {
+ const baseNodePermissions = this.reflectorInner.getAllAndOverride(
+ BASE_NODE_PERMISSIONS_KEY,
+ [context.getHandler(), context.getClass()]
+ );
+
+ if (!baseNodePermissions?.length) {
+ return true;
+ }
+ const nodeId = this.getNodeId(context);
+ const node = await this.getNode(baseId, nodeId);
+ const checkCreate = checkBaseNodePermissionCreate(
+ node ?? { resourceType: this.getNodeResourceType(context), resourceId: '' },
+ baseNodePermissions,
+ permissionContext
+ );
+
+ if (!checkCreate) {
+ return false;
+ }
+
+ const baseNodePermissionsWithoutCreate = baseNodePermissions.filter(
+ (permission: BaseNodeAction) => permission !== 'base_node|create'
+ );
+ if (!baseNodePermissionsWithoutCreate.length) {
+ return true;
+ }
+
+ if (!nodeId) {
+ throw new CustomHttpException('Node ID is required', HttpErrorCode.RESTRICTED_RESOURCE);
+ }
+
+ if (!node) {
+ throw new CustomHttpException('Node not found', HttpErrorCode.NOT_FOUND);
+ }
+
+ return baseNodePermissionsWithoutCreate.every((permission: BaseNodeAction) =>
+ checkBaseNodePermission(node, permission, permissionContext)
+ );
+ }
+
+ getBaseId(context: ExecutionContext): string | undefined {
+ const request = context.switchToHttp().getRequest();
+ const defaultBaseId = request.params ?? {};
+ return super.getResourceId(context) || defaultBaseId.baseId;
+ }
+
+ getNodeId(context: ExecutionContext): string | undefined {
+ const req = context.switchToHttp().getRequest();
+ return req.params.nodeId;
+ }
+
+ getNodeResourceType(context: ExecutionContext): BaseNodeResourceType {
+ const req = context.switchToHttp().getRequest();
+ return req.body.resourceType;
+ }
+
+ async getNode(baseId: string, nodeId?: string) {
+ if (!nodeId) {
+ return;
+ }
+ const node = await this.prismaService.baseNode.findFirst({
+ where: { baseId, id: nodeId },
+ select: {
+ id: true,
+ resourceType: true,
+ resourceId: true,
+ },
+ });
+
+ if (node) {
+ return {
+ resourceType: node.resourceType as BaseNodeResourceType,
+ resourceId: node.resourceId,
+ };
+ }
+ }
+
+ private async getPermissionContext() {
+ const permissions = this.clsInner.get('permissions');
+ const permissionSet = new Set(permissions);
+ return { permissionSet };
+ }
+}
diff --git a/apps/nestjs-backend/src/features/auth/permission.module.ts b/apps/nestjs-backend/src/features/auth/permission.module.ts
index 01343ad4f0..cb23f912f0 100644
--- a/apps/nestjs-backend/src/features/auth/permission.module.ts
+++ b/apps/nestjs-backend/src/features/auth/permission.module.ts
@@ -1,10 +1,11 @@
import { Global, Module } from '@nestjs/common';
+import { BaseNodePermissionGuard } from './guard/base-node-permission.guard';
import { PermissionGuard } from './guard/permission.guard';
import { PermissionService } from './permission.service';
@Global()
@Module({
- providers: [PermissionService, PermissionGuard],
- exports: [PermissionService, PermissionGuard],
+ providers: [PermissionService, PermissionGuard, BaseNodePermissionGuard],
+ exports: [PermissionService, PermissionGuard, BaseNodePermissionGuard],
})
export class PermissionModule {}
diff --git a/apps/nestjs-backend/src/features/base-node/base-node.controller.ts b/apps/nestjs-backend/src/features/base-node/base-node.controller.ts
new file mode 100644
index 0000000000..5c4fbd1c7d
--- /dev/null
+++ b/apps/nestjs-backend/src/features/base-node/base-node.controller.ts
@@ -0,0 +1,136 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
+import type { IBaseNodeTreeVo, IBaseNodeVo } from '@teable/openapi';
+import {
+ moveBaseNodeRoSchema,
+ createBaseNodeRoSchema,
+ duplicateBaseNodeRoSchema,
+ ICreateBaseNodeRo,
+ IDuplicateBaseNodeRo,
+ IMoveBaseNodeRo,
+ updateBaseNodeRoSchema,
+ IUpdateBaseNodeRo,
+} from '@teable/openapi';
+import { ClsService } from 'nestjs-cls';
+import type { IClsStore } from '../../types/cls';
+import { ZodValidationPipe } from '../../zod.validation.pipe';
+import { BaseNodePermissions } from '../auth/decorators/base-node-permissions.decorator';
+import { Permissions } from '../auth/decorators/permissions.decorator';
+import { BaseNodePermissionGuard } from '../auth/guard/base-node-permission.guard';
+import { checkBaseNodePermission } from './base-node.permission.helper';
+import { BaseNodeService } from './base-node.service';
+
+@Controller('api/base/:baseId/node')
+@UseGuards(BaseNodePermissionGuard)
+export class BaseNodeController {
+ constructor(
+ private readonly baseNodeService: BaseNodeService,
+ private readonly cls: ClsService
+ ) {}
+
+ @Get('list')
+ @Permissions('base|read')
+ async getList(@Param('baseId') baseId: string): Promise {
+ const permissionContext = await this.getPermissionContext(baseId);
+ const nodeList = await this.baseNodeService.getList(baseId);
+ return nodeList.filter((node) =>
+ checkBaseNodePermission(
+ { resourceType: node.resourceType, resourceId: node.resourceId },
+ 'base_node|read',
+ permissionContext
+ )
+ );
+ }
+
+ @Get('tree')
+ @Permissions('base|read')
+ async getTree(@Param('baseId') baseId: string): Promise {
+ const permissionContext = await this.getPermissionContext(baseId);
+ const tree = await this.baseNodeService.getTree(baseId);
+ return {
+ ...tree,
+ nodes: tree.nodes.filter((node) =>
+ checkBaseNodePermission(
+ { resourceType: node.resourceType, resourceId: node.resourceId },
+ 'base_node|read',
+ permissionContext
+ )
+ ),
+ };
+ }
+
+ @Get(':nodeId')
+ @Permissions('base|read')
+ @BaseNodePermissions('base_node|read')
+ async getNode(
+ @Param('baseId') baseId: string,
+ @Param('nodeId') nodeId: string
+ ): Promise {
+ return this.baseNodeService.getNodeVo(baseId, nodeId);
+ }
+
+ @Post()
+ @Permissions('base|read')
+ @BaseNodePermissions('base_node|create')
+ async create(
+ @Param('baseId') baseId: string,
+ @Body(new ZodValidationPipe(createBaseNodeRoSchema)) ro: ICreateBaseNodeRo
+ ): Promise {
+ return this.baseNodeService.create(baseId, ro);
+ }
+
+ @Post(':nodeId/duplicate')
+ @Permissions('base|read')
+ @BaseNodePermissions('base_node|read', 'base_node|create')
+ async duplicate(
+ @Param('baseId') baseId: string,
+ @Param('nodeId') nodeId: string,
+ @Body(new ZodValidationPipe(duplicateBaseNodeRoSchema)) ro: IDuplicateBaseNodeRo
+ ): Promise {
+ return this.baseNodeService.duplicate(baseId, nodeId, ro);
+ }
+
+ @Put(':nodeId')
+ @Permissions('base|read')
+ @BaseNodePermissions('base_node|update')
+ async update(
+ @Param('baseId') baseId: string,
+ @Param('nodeId') nodeId: string,
+ @Body(new ZodValidationPipe(updateBaseNodeRoSchema)) ro: IUpdateBaseNodeRo
+ ): Promise {
+ return this.baseNodeService.update(baseId, nodeId, ro);
+ }
+
+ @Put(':nodeId/move')
+ @Permissions('base|update')
+ async move(
+ @Param('baseId') baseId: string,
+ @Param('nodeId') nodeId: string,
+ @Body(new ZodValidationPipe(moveBaseNodeRoSchema)) ro: IMoveBaseNodeRo
+ ): Promise {
+ return this.baseNodeService.move(baseId, nodeId, ro);
+ }
+
+ @Delete(':nodeId')
+ @Permissions('base|read')
+ @BaseNodePermissions('base_node|delete')
+ async delete(@Param('baseId') baseId: string, @Param('nodeId') nodeId: string): Promise {
+ return this.baseNodeService.delete(baseId, nodeId);
+ }
+
+ @Delete(':nodeId/permanent')
+ @Permissions('base|read')
+ @BaseNodePermissions('base_node|delete')
+ async permanentDelete(
+ @Param('baseId') baseId: string,
+ @Param('nodeId') nodeId: string
+ ): Promise {
+ return this.baseNodeService.delete(baseId, nodeId, true);
+ }
+
+ protected async getPermissionContext(_baseId: string) {
+ const permissions = this.cls.get('permissions');
+ const permissionSet = new Set(permissions);
+ return { permissionSet };
+ }
+}
diff --git a/apps/nestjs-backend/src/features/base-node/base-node.module.ts b/apps/nestjs-backend/src/features/base-node/base-node.module.ts
new file mode 100644
index 0000000000..61a7b0200c
--- /dev/null
+++ b/apps/nestjs-backend/src/features/base-node/base-node.module.ts
@@ -0,0 +1,27 @@
+import { Module } from '@nestjs/common';
+import { ShareDbModule } from '../../share-db/share-db.module';
+import { DashboardModule } from '../dashboard/dashboard.module';
+import { FieldDuplicateModule } from '../field/field-duplicate/field-duplicate.module';
+import { FieldOpenApiModule } from '../field/open-api/field-open-api.module';
+import { TableOpenApiModule } from '../table/open-api/table-open-api.module';
+import { TableDuplicateService } from '../table/table-duplicate.service';
+import { TableModule } from '../table/table.module';
+import { BaseNodeController } from './base-node.controller';
+import { BaseNodeService } from './base-node.service';
+import { BaseNodeFolderModule } from './folder/base-node-folder.module';
+
+@Module({
+ imports: [
+ BaseNodeFolderModule,
+ ShareDbModule,
+ DashboardModule,
+ TableOpenApiModule,
+ TableModule,
+ FieldOpenApiModule,
+ FieldDuplicateModule,
+ ],
+ controllers: [BaseNodeController],
+ providers: [BaseNodeService, TableDuplicateService],
+ exports: [BaseNodeService],
+})
+export class BaseNodeModule {}
diff --git a/apps/nestjs-backend/src/features/base-node/base-node.permission.helper.ts b/apps/nestjs-backend/src/features/base-node/base-node.permission.helper.ts
new file mode 100644
index 0000000000..a3eb6dd77a
--- /dev/null
+++ b/apps/nestjs-backend/src/features/base-node/base-node.permission.helper.ts
@@ -0,0 +1,171 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import { HttpErrorCode, type BaseNodeAction } from '@teable/core';
+import { BaseNodeResourceType } from '@teable/openapi';
+import { CustomHttpException } from '../../custom.exception';
+
+const checkBaseNodeRead = (
+ node: { resourceType: BaseNodeResourceType; resourceId: string },
+ permissionContext: {
+ permissionSet: Set;
+ tablePermissionMap?: Record;
+ }
+): boolean => {
+ const { permissionSet, tablePermissionMap } = permissionContext;
+ const { resourceType, resourceId } = node;
+ if (resourceType === BaseNodeResourceType.Folder) {
+ return permissionSet.has('base|read');
+ }
+ if (resourceType === BaseNodeResourceType.Table) {
+ if (tablePermissionMap) {
+ return tablePermissionMap[resourceId]?.includes('table|read') ?? false;
+ }
+ return permissionSet.has('table|read');
+ }
+ if (resourceType === BaseNodeResourceType.Dashboard) {
+ return permissionSet.has('base|read');
+ }
+ if (resourceType === BaseNodeResourceType.Workflow) {
+ return permissionSet.has('automation|read');
+ }
+ if (resourceType === BaseNodeResourceType.App) {
+ return permissionSet.has('base|read');
+ }
+ return true;
+};
+
+const checkBaseNodeCreate = (
+ node: { resourceType: BaseNodeResourceType; resourceId: string },
+ permissionContext: {
+ permissionSet: Set;
+ tablePermissionMap?: Record;
+ }
+): boolean => {
+ const { permissionSet } = permissionContext;
+ const { resourceType } = node;
+ if (resourceType === BaseNodeResourceType.Folder) {
+ return permissionSet.has('base|update');
+ }
+ if (resourceType === BaseNodeResourceType.Table) {
+ return permissionSet.has('table|create');
+ }
+ if (resourceType === BaseNodeResourceType.Dashboard) {
+ return permissionSet.has('base|update');
+ }
+ if (resourceType === BaseNodeResourceType.Workflow) {
+ return permissionSet.has('automation|create');
+ }
+ if (resourceType === BaseNodeResourceType.App) {
+ return permissionSet.has('base|update');
+ }
+ return true;
+};
+
+const checkBaseNodeUpdate = (
+ node: { resourceType: BaseNodeResourceType; resourceId: string },
+ permissionContext: {
+ tablePermissionMap?: Record;
+ permissionSet: Set;
+ }
+): boolean => {
+ const { permissionSet, tablePermissionMap } = permissionContext;
+ const { resourceType, resourceId } = node;
+ if (resourceType === BaseNodeResourceType.Folder) {
+ return permissionSet.has('base|update');
+ }
+ if (resourceType === BaseNodeResourceType.Table) {
+ if (tablePermissionMap) {
+ return tablePermissionMap[resourceId]?.includes('table|update') ?? false;
+ }
+ return permissionSet.has('table|update');
+ }
+ if (resourceType === BaseNodeResourceType.Dashboard) {
+ return permissionSet.has('base|update');
+ }
+ if (resourceType === BaseNodeResourceType.Workflow) {
+ return permissionSet.has('automation|update');
+ }
+ if (resourceType === BaseNodeResourceType.App) {
+ return permissionSet.has('base|update');
+ }
+ return true;
+};
+
+const checkBaseNodeDelete = (
+ node: { resourceType: BaseNodeResourceType; resourceId: string },
+ permissionContext: {
+ tablePermissionMap?: Record;
+ permissionSet: Set;
+ }
+): boolean => {
+ const { permissionSet, tablePermissionMap } = permissionContext;
+ const { resourceType, resourceId } = node;
+ if (resourceType === BaseNodeResourceType.Folder) {
+ return permissionSet.has('base|update');
+ }
+ if (resourceType === BaseNodeResourceType.Table) {
+ if (tablePermissionMap) {
+ return tablePermissionMap[resourceId]?.includes('table|delete') ?? false;
+ }
+ return permissionSet.has('table|delete');
+ }
+ if (resourceType === BaseNodeResourceType.Dashboard) {
+ return permissionSet.has('base|update');
+ }
+ if (resourceType === BaseNodeResourceType.Workflow) {
+ return permissionSet.has('automation|delete');
+ }
+ if (resourceType === BaseNodeResourceType.App) {
+ return permissionSet.has('base|update');
+ }
+ return true;
+};
+
+export const checkBaseNodePermission = (
+ node: { resourceType: BaseNodeResourceType; resourceId: string },
+ action: BaseNodeAction,
+ permissionContext: {
+ tablePermissionMap?: Record;
+ permissionSet: Set;
+ }
+): boolean => {
+ switch (action) {
+ case 'base_node|read':
+ return checkBaseNodeRead(node, permissionContext);
+ case 'base_node|create':
+ return checkBaseNodeCreate(node, permissionContext);
+ case 'base_node|update':
+ return checkBaseNodeUpdate(node, permissionContext);
+ case 'base_node|delete':
+ return checkBaseNodeDelete(node, permissionContext);
+ default:
+ return false;
+ }
+};
+
+export const checkBaseNodePermissionCreate = (
+ node: { resourceType: BaseNodeResourceType; resourceId: string },
+ baseNodePermissions: BaseNodeAction[],
+ permissionContext: {
+ tablePermissionMap?: Record;
+ permissionSet: Set;
+ }
+): boolean => {
+ const checkCreate = baseNodePermissions.includes('base_node|create');
+ if (!checkCreate) {
+ return true;
+ }
+ const { resourceType } = node;
+ if (!resourceType) {
+ throw new CustomHttpException(
+ 'Cannot create base node with empty resource type',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.invalidResourceType',
+ },
+ }
+ );
+ }
+
+ return checkBaseNodePermission(node, 'base_node|create', permissionContext);
+};
diff --git a/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts b/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts
new file mode 100644
index 0000000000..1b28a2f0aa
--- /dev/null
+++ b/apps/nestjs-backend/src/features/base-node/base-node.service.spec.ts
@@ -0,0 +1,130 @@
+import type { TestingModule } from '@nestjs/testing';
+import { Test } from '@nestjs/testing';
+import { GlobalModule } from '../../global/global.module';
+import { BaseNodeModule } from './base-node.module';
+import { BaseNodeService } from './base-node.service';
+
+describe('BaseNodeService', () => {
+ let service: BaseNodeService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ imports: [GlobalModule, BaseNodeModule],
+ }).compile();
+
+ service = module.get(BaseNodeService);
+ });
+
+ it('should be defined', () => {
+ expect(service).toBeDefined();
+ });
+
+ describe('buildBatchUpdateSql', () => {
+ it('should return null for empty data', () => {
+ const result = service.buildBatchUpdateSql([]);
+ expect(result).toBeNull();
+ });
+
+ it('should return null for data with empty values', () => {
+ const result = service.buildBatchUpdateSql([{ id: 'node1', values: {} }]);
+ expect(result).toBeNull();
+ });
+
+ it('should build SQL for single record with single field', () => {
+ const result = service.buildBatchUpdateSql([{ id: 'node1', values: { order: 1 } }]);
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('update "base_node"');
+ expect(result).toContain('"order"');
+ expect(result).toContain(`CASE WHEN "id" = 'node1' THEN 1 ELSE "order" END`);
+ expect(result).toContain(`where "id" in ('node1')`);
+ });
+
+ it('should build SQL for single record with multiple fields', () => {
+ const result = service.buildBatchUpdateSql([
+ { id: 'node1', values: { parentId: null, order: 5 } },
+ ]);
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('"parent_id"'); // camelCase -> snake_case
+ expect(result).toContain('"order"');
+ expect(result).toContain(`CASE WHEN "id" = 'node1' THEN NULL ELSE "parent_id" END`);
+ expect(result).toContain(`CASE WHEN "id" = 'node1' THEN 5 ELSE "order" END`);
+ });
+
+ it('should build SQL for multiple records with same fields', () => {
+ const result = service.buildBatchUpdateSql([
+ { id: 'node1', values: { order: 1 } },
+ { id: 'node2', values: { order: 2 } },
+ { id: 'node3', values: { order: 3 } },
+ ]);
+
+ expect(result).not.toBeNull();
+ // Should have multiple WHEN clauses in single CASE
+ expect(result).toContain(`WHEN "id" = 'node1' THEN 1`);
+ expect(result).toContain(`WHEN "id" = 'node2' THEN 2`);
+ expect(result).toContain(`WHEN "id" = 'node3' THEN 3`);
+ expect(result).toContain(`where "id" in ('node1', 'node2', 'node3')`);
+ });
+
+ it('should build SQL for multiple records with different fields', () => {
+ const result = service.buildBatchUpdateSql([
+ { id: 'node1', values: { parentId: 'folder1', order: 1 } },
+ { id: 'node2', values: { order: 2 } }, // only order
+ { id: 'node3', values: { parentId: null } }, // only parentId
+ ]);
+
+ expect(result).not.toBeNull();
+ // parentId CASE should have node1 and node3
+ expect(result).toMatch(/CASE.*node1.*node3.*parent_id.*END/s);
+ // order CASE should have node1 and node2
+ expect(result).toMatch(/CASE.*node1.*node2.*order.*END/s);
+ // All ids in WHERE clause
+ expect(result).toContain(`where "id" in ('node1', 'node2', 'node3')`);
+ });
+
+ it('should handle string values correctly', () => {
+ const result = service.buildBatchUpdateSql([
+ { id: 'node1', values: { resourceType: 'table' } },
+ ]);
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('"resource_type"');
+ expect(result).toContain("'table'");
+ });
+
+ it('should convert camelCase keys to snake_case columns', () => {
+ const result = service.buildBatchUpdateSql([
+ { id: 'node1', values: { parentId: 'p1', resourceType: 'dashboard', createdBy: 'user1' } },
+ ]);
+
+ expect(result).not.toBeNull();
+ expect(result).toContain('"parent_id"');
+ expect(result).toContain('"resource_type"');
+ expect(result).toContain('"created_by"');
+ // Should not contain camelCase versions (without quotes)
+ expect(result).not.toMatch(/[^"]parentId[^"]/);
+ expect(result).not.toMatch(/[^"]resourceType[^"]/);
+ expect(result).not.toMatch(/[^"]createdBy[^"]/);
+ });
+
+ it('should build complete SQL for multiple records with multiple fields', () => {
+ const result = service.buildBatchUpdateSql([
+ { id: 'bnod001', values: { parentId: null, order: 10 } },
+ { id: 'bnod002', values: { parentId: 'folder1', order: 20 } },
+ { id: 'bnod003', values: { parentId: 'folder2', order: 30 } },
+ ]);
+
+ expect(result).not.toBeNull();
+
+ // Verify complete SQL structure
+ const expectedSql =
+ 'update "base_node" set ' +
+ `"parent_id" = CASE WHEN "id" = 'bnod001' THEN NULL WHEN "id" = 'bnod002' THEN 'folder1' WHEN "id" = 'bnod003' THEN 'folder2' ELSE "parent_id" END, ` +
+ `"order" = CASE WHEN "id" = 'bnod001' THEN 10 WHEN "id" = 'bnod002' THEN 20 WHEN "id" = 'bnod003' THEN 30 ELSE "order" END ` +
+ `where "id" in ('bnod001', 'bnod002', 'bnod003')`;
+
+ expect(result).toBe(expectedSql);
+ });
+ });
+});
diff --git a/apps/nestjs-backend/src/features/base-node/base-node.service.ts b/apps/nestjs-backend/src/features/base-node/base-node.service.ts
new file mode 100644
index 0000000000..02e0c07a5d
--- /dev/null
+++ b/apps/nestjs-backend/src/features/base-node/base-node.service.ts
@@ -0,0 +1,1451 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import { Injectable, Logger } from '@nestjs/common';
+import { OnEvent } from '@nestjs/event-emitter';
+import {
+ ANONYMOUS_USER_ID,
+ generateBaseNodeId,
+ getBaseNodeChannel,
+ HttpErrorCode,
+} from '@teable/core';
+import type { Prisma } from '@teable/db-main-prisma';
+import { PrismaService } from '@teable/db-main-prisma';
+import type {
+ IBaseNodePresenceCreatePayload,
+ IBaseNodePresenceDeletePayload,
+ IMoveBaseNodeRo,
+ IBaseNodeVo,
+ IBaseNodeTreeVo,
+ IBaseNodePresenceUpdatePayload,
+ ICreateBaseNodeRo,
+ IDuplicateBaseNodeRo,
+ IDuplicateTableRo,
+ ICreateDashboardRo,
+ ICreateFolderNodeRo,
+ IDuplicateDashboardRo,
+ IUpdateBaseNodeRo,
+ IBaseNodePresenceFlushPayload,
+ IBaseNodeResourceMeta,
+ IBaseNodeResourceMetaWithId,
+ ICreateTableRo,
+} from '@teable/openapi';
+import { BaseNodeResourceType } from '@teable/openapi';
+import { Knex } from 'knex';
+import { isString, keyBy, omit, snakeCase } from 'lodash';
+import { InjectModel } from 'nest-knexjs';
+import { ClsService } from 'nestjs-cls';
+import type { LocalPresence } from 'sharedb/lib/client';
+import { CustomHttpException } from '../../custom.exception';
+import type {
+ BaseFolderUpdateEvent,
+ BaseFolderDeleteEvent,
+ TableDeleteEvent,
+ TableUpdateEvent,
+ TableCreateEvent,
+ BaseFolderCreateEvent,
+} from '../../event-emitter/events';
+import type {
+ AppCreateEvent,
+ AppDeleteEvent,
+ AppUpdateEvent,
+} from '../../event-emitter/events/app/app.event';
+import type { BaseDeleteEvent } from '../../event-emitter/events/base/base.event';
+import type {
+ DashboardCreateEvent,
+ DashboardDeleteEvent,
+ DashboardUpdateEvent,
+} from '../../event-emitter/events/dashboard/dashboard.event';
+import { Events } from '../../event-emitter/events/event.enum';
+import type {
+ WorkflowCreateEvent,
+ WorkflowDeleteEvent,
+ WorkflowUpdateEvent,
+} from '../../event-emitter/events/workflow/workflow.event';
+import { generateBaseNodeListCacheKey } from '../../performance-cache/generate-keys';
+import { PerformanceCacheService } from '../../performance-cache/service';
+import type { IPerformanceCacheStore } from '../../performance-cache/types';
+import { ShareDbService } from '../../share-db/share-db.service';
+import type { IClsStore } from '../../types/cls';
+import { updateOrder } from '../../utils/update-order';
+import { DashboardService } from '../dashboard/dashboard.service';
+import { TableOpenApiService } from '../table/open-api/table-open-api.service';
+import { prepareCreateTableRo } from '../table/open-api/table.pipe.helper';
+import { TableDuplicateService } from '../table/table-duplicate.service';
+import { BaseNodeFolderService } from './folder/base-node-folder.service';
+
+type IResourceCreateEvent =
+ | BaseFolderCreateEvent
+ | TableCreateEvent
+ | WorkflowCreateEvent
+ | DashboardCreateEvent
+ | AppCreateEvent;
+
+type IResourceDeleteEvent =
+ | BaseDeleteEvent
+ | BaseFolderDeleteEvent
+ | TableDeleteEvent
+ | WorkflowDeleteEvent
+ | DashboardDeleteEvent
+ | AppDeleteEvent;
+
+type IResourceUpdateEvent =
+ | BaseFolderUpdateEvent
+ | TableUpdateEvent
+ | WorkflowUpdateEvent
+ | DashboardUpdateEvent
+ | AppUpdateEvent;
+
+type IBaseNodeEntry = {
+ id: string;
+ baseId: string;
+ parentId: string | null;
+ resourceType: string;
+ resourceId: string;
+ order: number;
+ children: { id: string; order: number }[];
+ parent: { id: string } | null;
+};
+
+// max depth is maxFolderDepth + 1
+const maxFolderDepth = 2;
+
+@Injectable()
+export class BaseNodeService {
+ private readonly logger = new Logger(BaseNodeService.name);
+ constructor(
+ private readonly performanceCacheService: PerformanceCacheService,
+ private readonly prismaService: PrismaService,
+ @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
+ private readonly cls: ClsService,
+ private readonly shareDbService: ShareDbService,
+ private readonly baseNodeFolderService: BaseNodeFolderService,
+ private readonly tableOpenApiService: TableOpenApiService,
+ private readonly tableDuplicateService: TableDuplicateService,
+ private readonly dashboardService: DashboardService
+ ) {}
+
+ private get userId() {
+ return this.cls.get('user.id');
+ }
+
+ private getSelect() {
+ return {
+ id: true,
+ baseId: true,
+ parentId: true,
+ resourceType: true,
+ resourceId: true,
+ order: true,
+ children: {
+ select: { id: true, order: true },
+ orderBy: { order: 'asc' as const },
+ },
+ parent: {
+ select: { id: true },
+ },
+ };
+ }
+
+ private async entry2vo(
+ entry: IBaseNodeEntry,
+ resource?: IBaseNodeResourceMeta
+ ): Promise {
+ if (resource) {
+ return {
+ ...entry,
+ resourceType: entry.resourceType as BaseNodeResourceType,
+ resourceMeta: resource,
+ };
+ }
+ const { resourceType, resourceId } = entry;
+ const list = await this.getNodeResource(entry.baseId, resourceType as BaseNodeResourceType, [
+ resourceId,
+ ]);
+ const resourceMeta = list[0];
+ return {
+ ...entry,
+ resourceType: resourceType as BaseNodeResourceType,
+ resourceMeta: omit(resourceMeta, 'id'),
+ };
+ }
+
+ private presenceHandler<
+ T =
+ | IBaseNodePresenceFlushPayload
+ | IBaseNodePresenceCreatePayload
+ | IBaseNodePresenceUpdatePayload
+ | IBaseNodePresenceDeletePayload,
+ >(baseId: string, handler: (presence: LocalPresence) => void) {
+ this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));
+ // Skip if ShareDB connection is already closed (e.g., during shutdown)
+ if (this.shareDbService.shareDbAdapter.closed) {
+ this.logger.error('ShareDB connection is already closed, presence handler skipped');
+ return;
+ }
+ const channel = getBaseNodeChannel(baseId);
+ const presence = this.shareDbService.connect().getPresence(channel);
+ const localPresence = presence.create(channel);
+ handler(localPresence);
+ localPresence.destroy();
+ }
+
+ protected getTableResources(baseId: string, ids?: string[]) {
+ return this.prismaService.tableMeta.findMany({
+ where: { baseId, id: { in: ids ? ids : undefined }, deletedTime: null },
+ select: {
+ id: true,
+ name: true,
+ icon: true,
+ },
+ });
+ }
+
+ protected getDashboardResources(baseId: string, ids?: string[]) {
+ return this.prismaService.dashboard.findMany({
+ where: { baseId, id: { in: ids ? ids : undefined } },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+ }
+
+ protected getFolderResources(baseId: string, ids?: string[]) {
+ return this.prismaService.baseNodeFolder.findMany({
+ where: { baseId, id: { in: ids ? ids : undefined } },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+ }
+
+ protected async getNodeResource(
+ baseId: string,
+ type: BaseNodeResourceType,
+ ids?: string[]
+ ): Promise {
+ switch (type) {
+ case BaseNodeResourceType.Folder:
+ return this.getFolderResources(baseId, ids);
+ case BaseNodeResourceType.Table:
+ return this.getTableResources(baseId, ids);
+ case BaseNodeResourceType.Dashboard:
+ return this.getDashboardResources(baseId, ids);
+ default:
+ throw new CustomHttpException(
+ `Invalid resource type ${type}`,
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.invalidResourceType',
+ },
+ }
+ );
+ }
+ }
+
+ protected getResourceTypes(): BaseNodeResourceType[] {
+ return [
+ BaseNodeResourceType.Folder,
+ BaseNodeResourceType.Table,
+ BaseNodeResourceType.Dashboard,
+ ];
+ }
+
+ async prepareNodeList(baseId: string): Promise {
+ const resourceTypes = this.getResourceTypes();
+ const resourceResults = await Promise.all(
+ resourceTypes.map((type) => this.getNodeResource(baseId, type))
+ );
+
+ const resources = resourceResults.flatMap((list, index) =>
+ list.map((r) => ({ ...r, type: resourceTypes[index] }))
+ );
+
+ const resourceMap = keyBy(resources, (r) => `${r.type}_${r.id}`);
+ const resourceKeys = new Set(resources.map((r) => `${r.type}_${r.id}`));
+
+ const nodes = await this.prismaService.baseNode.findMany({
+ where: { baseId },
+ select: this.getSelect(),
+ orderBy: { order: 'asc' },
+ });
+
+ const nodeKeys = new Set(nodes.map((n) => `${n.resourceType}_${n.resourceId}`));
+
+ const toCreate = resources.filter((r) => !nodeKeys.has(`${r.type}_${r.id}`));
+ const toDelete = nodes.filter((n) => !resourceKeys.has(`${n.resourceType}_${n.resourceId}`));
+ const validParentIds = new Set(nodes.filter((n) => !toDelete.includes(n)).map((n) => n.id));
+ const orphans = nodes.filter(
+ (n) => n.parentId && !validParentIds.has(n.parentId) && !toDelete.includes(n)
+ );
+
+ if (toCreate.length === 0 && toDelete.length === 0 && orphans.length === 0) {
+ return nodes.map((entry) => {
+ const key = `${entry.resourceType}_${entry.resourceId}`;
+ const resource = resourceMap[key];
+ return {
+ ...entry,
+ resourceType: entry.resourceType as BaseNodeResourceType,
+ resourceMeta: omit(resource, 'id'),
+ };
+ });
+ }
+
+ const finalMenus = await this.prismaService.$tx(async (prisma) => {
+ // Delete redundant
+ if (toDelete.length > 0) {
+ await prisma.baseNode.deleteMany({
+ where: { id: { in: toDelete.map((m) => m.id) } },
+ });
+ }
+
+ // Prepare for create and update
+ let nextOrder = 0;
+ if (toCreate.length > 0 || orphans.length > 0) {
+ const maxOrderAgg = await prisma.baseNode.aggregate({
+ where: { baseId },
+ _max: { order: true },
+ });
+ nextOrder = (maxOrderAgg._max.order ?? 0) + 1;
+ }
+
+ // Create missing
+ if (toCreate.length > 0) {
+ await prisma.baseNode.createMany({
+ data: toCreate.map((r) => ({
+ id: generateBaseNodeId(),
+ baseId,
+ resourceType: r.type,
+ resourceId: r.id,
+ order: nextOrder++,
+ parentId: null,
+ createdBy: this.userId,
+ })),
+ });
+ }
+
+ // Reset orphans to root level with new order
+ if (orphans.length > 0) {
+ await this.batchUpdateBaseNodes(
+ orphans.map((orphan, index) => ({
+ id: orphan.id,
+ values: { parentId: null, order: nextOrder + index },
+ }))
+ );
+ }
+ return prisma.baseNode.findMany({
+ where: { baseId },
+ select: this.getSelect(),
+ orderBy: { order: 'asc' },
+ });
+ });
+
+ return await Promise.all(
+ finalMenus.map(async (entry) => {
+ const key = `${entry.resourceType}_${entry.resourceId}`;
+ const resource = resourceMap[key];
+ return await this.entry2vo(entry, omit(resource, 'id'));
+ })
+ );
+ }
+
+ async getNodeListWithCache(baseId: string): Promise {
+ return this.performanceCacheService.wrap(
+ generateBaseNodeListCacheKey(baseId),
+ () => this.prepareNodeList(baseId),
+ {
+ ttl: 60 * 60, // 1 hour
+ statsType: 'base-node-list',
+ }
+ );
+ }
+
+ async getList(baseId: string): Promise {
+ return this.getNodeListWithCache(baseId);
+ }
+
+ async getTree(baseId: string): Promise {
+ const nodes = await this.getNodeListWithCache(baseId);
+
+ return {
+ nodes,
+ maxFolderDepth,
+ };
+ }
+
+ async getNode(baseId: string, nodeId: string) {
+ const node = await this.prismaService.baseNode
+ .findFirstOrThrow({
+ where: { baseId, id: nodeId },
+ select: this.getSelect(),
+ })
+ .catch(() => {
+ throw new CustomHttpException(`Base node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.notFound',
+ },
+ });
+ });
+ return {
+ ...node,
+ resourceType: node.resourceType as BaseNodeResourceType,
+ };
+ }
+
+ async getNodeVo(baseId: string, nodeId: string): Promise {
+ const node = await this.getNode(baseId, nodeId);
+ return this.entry2vo(node);
+ }
+
+ async create(baseId: string, ro: ICreateBaseNodeRo): Promise {
+ const { resourceType, parentId } = ro;
+
+ const { entry, resource } = await this.prismaService.$tx(async (prisma) => {
+ const resource = await this.createResource(baseId, ro);
+ const resourceId = resource.id;
+
+ const maxOrder = await this.getMaxOrder(baseId);
+ const entry = await prisma.baseNode.create({
+ data: {
+ id: generateBaseNodeId(),
+ baseId,
+ resourceType,
+ resourceId,
+ order: maxOrder + 1,
+ parentId,
+ createdBy: this.userId,
+ },
+ select: this.getSelect(),
+ });
+
+ return {
+ entry,
+ resource,
+ };
+ });
+
+ const vo = await this.entry2vo(entry, omit(resource, 'id'));
+ this.presenceHandler(baseId, (presence) => {
+ presence.submit({
+ event: 'create',
+ data: { ...vo },
+ });
+ });
+ return vo;
+ }
+
+ protected async createResource(
+ baseId: string,
+ createRo: ICreateBaseNodeRo
+ ): Promise {
+ const { resourceType, parentId, ...ro } = createRo;
+ const parentNode = parentId ? await this.getParentNodeOrThrow(parentId) : null;
+ if (parentNode && parentNode.resourceType !== BaseNodeResourceType.Folder) {
+ throw new CustomHttpException('Parent must be a folder', HttpErrorCode.VALIDATION_ERROR, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.parentMustBeFolder',
+ },
+ });
+ }
+
+ if (parentNode && resourceType === BaseNodeResourceType.Folder) {
+ await this.assertFolderDepth(baseId, parentNode.id);
+ }
+
+ switch (resourceType) {
+ case BaseNodeResourceType.Folder: {
+ const folder = await this.baseNodeFolderService.createFolder(
+ baseId,
+ ro as ICreateFolderNodeRo
+ );
+ return { id: folder.id, name: folder.name };
+ }
+ case BaseNodeResourceType.Table: {
+ const preparedRo = prepareCreateTableRo(ro as ICreateTableRo);
+ const table = await this.tableOpenApiService.createTable(baseId, preparedRo);
+
+ return {
+ id: table.id,
+ name: table.name,
+ icon: table.icon,
+ defaultViewId: table.defaultViewId,
+ };
+ }
+ case BaseNodeResourceType.Dashboard: {
+ const dashboard = await this.dashboardService.createDashboard(
+ baseId,
+ ro as ICreateDashboardRo
+ );
+ return { id: dashboard.id, name: dashboard.name };
+ }
+ default:
+ throw new CustomHttpException(
+ `Invalid resource type ${resourceType}`,
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.invalidResourceType',
+ },
+ }
+ );
+ }
+ }
+
+ async duplicate(baseId: string, nodeId: string, ro: IDuplicateBaseNodeRo) {
+ const anchor = await this.prismaService.baseNode
+ .findFirstOrThrow({
+ where: { baseId, id: nodeId },
+ })
+ .catch(() => {
+ throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.notFound',
+ },
+ });
+ });
+ const { resourceType, resourceId } = anchor;
+
+ if (resourceType === BaseNodeResourceType.Folder) {
+ throw new CustomHttpException('Cannot duplicate folder', HttpErrorCode.VALIDATION_ERROR, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.cannotDuplicateFolder',
+ },
+ });
+ }
+
+ const { entry, resource } = await this.prismaService.$tx(async (prisma) => {
+ const resource = await this.duplicateResource(
+ baseId,
+ resourceType as BaseNodeResourceType,
+ resourceId,
+ ro
+ );
+
+ const maxOrder = await this.getMaxOrder(baseId, anchor.parentId);
+ const newNodeId = generateBaseNodeId();
+ const entry = await prisma.baseNode.create({
+ data: {
+ id: newNodeId,
+ baseId,
+ resourceType,
+ resourceId: resource.id,
+ order: maxOrder + 1,
+ parentId: anchor.parentId,
+ createdBy: this.userId,
+ },
+ select: this.getSelect(),
+ });
+
+ await updateOrder({
+ query: baseId,
+ position: 'after',
+ item: entry,
+ anchorItem: anchor,
+ getNextItem: async (whereOrder, align) => {
+ return prisma.baseNode.findFirst({
+ where: {
+ baseId,
+ parentId: anchor.parentId,
+ order: whereOrder,
+ id: { not: newNodeId },
+ },
+ select: { order: true, id: true },
+ orderBy: { order: align },
+ });
+ },
+ update: async (_, id, data) => {
+ await prisma.baseNode.update({
+ where: { id },
+ data: { parentId: anchor.parentId, order: data.newOrder },
+ });
+ },
+ shuffle: async () => {
+ await this.shuffleOrders(baseId, anchor.parentId);
+ },
+ });
+
+ return {
+ entry,
+ resource,
+ };
+ });
+
+ const vo = await this.entry2vo(entry, omit(resource, 'id'));
+ this.presenceHandler(baseId, (presence) => {
+ presence.submit({
+ event: 'create',
+ data: { ...vo },
+ });
+ });
+ return vo;
+ }
+
+ protected async duplicateResource(
+ baseId: string,
+ type: BaseNodeResourceType,
+ id: string,
+ duplicateRo: IDuplicateBaseNodeRo
+ ): Promise {
+ switch (type) {
+ case BaseNodeResourceType.Table: {
+ const table = await this.tableDuplicateService.duplicateTable(
+ baseId,
+ id,
+ duplicateRo as IDuplicateTableRo
+ );
+
+ return {
+ id: table.id,
+ name: table.name,
+ icon: table.icon ?? undefined,
+ defaultViewId: table.defaultViewId,
+ };
+ }
+ case BaseNodeResourceType.Dashboard: {
+ const dashboard = await this.dashboardService.duplicateDashboard(
+ baseId,
+ id,
+ duplicateRo as IDuplicateDashboardRo
+ );
+ return { id: dashboard.id, name: dashboard.name };
+ }
+ default:
+ throw new CustomHttpException(
+ `Invalid resource type ${type}`,
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.invalidResourceType',
+ },
+ }
+ );
+ }
+ }
+
+ async update(baseId: string, nodeId: string, ro: IUpdateBaseNodeRo) {
+ const node = await this.prismaService.baseNode
+ .findFirstOrThrow({
+ where: { baseId, id: nodeId },
+ select: this.getSelect(),
+ })
+ .catch(() => {
+ throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.notFound',
+ },
+ });
+ });
+
+ await this.prismaService.$tx(async () => {
+ await this.updateResource(
+ baseId,
+ node.resourceType as BaseNodeResourceType,
+ node.resourceId,
+ ro
+ );
+ });
+
+ const vo = await this.entry2vo(node);
+ this.presenceHandler(baseId, (presence) => {
+ presence.submit({
+ event: 'update',
+ data: { ...vo },
+ });
+ });
+ return vo;
+ }
+
+ protected async updateResource(
+ baseId: string,
+ type: BaseNodeResourceType,
+ id: string,
+ updateRo: IUpdateBaseNodeRo
+ ): Promise {
+ const { name, icon } = updateRo;
+ switch (type) {
+ case BaseNodeResourceType.Folder:
+ if (name) {
+ await this.baseNodeFolderService.renameFolder(baseId, id, { name });
+ }
+ break;
+ case BaseNodeResourceType.Table:
+ if (name) {
+ await this.tableOpenApiService.updateName(baseId, id, name);
+ }
+ if (icon) {
+ await this.tableOpenApiService.updateIcon(baseId, id, icon);
+ }
+ break;
+ case BaseNodeResourceType.Dashboard:
+ if (name) {
+ await this.dashboardService.renameDashboard(baseId, id, name);
+ }
+ break;
+ default:
+ throw new CustomHttpException(
+ `Invalid resource type ${type}`,
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.invalidResourceType',
+ },
+ }
+ );
+ }
+ }
+
+ async delete(baseId: string, nodeId: string, permanent?: boolean) {
+ const node = await this.prismaService.baseNode
+ .findFirstOrThrow({
+ where: { baseId, id: nodeId },
+ })
+ .catch(() => {
+ throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.notFound',
+ },
+ });
+ });
+ if (node.resourceType === BaseNodeResourceType.Folder) {
+ const children = await this.prismaService.baseNode.findMany({
+ where: { baseId, parentId: nodeId },
+ });
+ if (children.length > 0) {
+ throw new CustomHttpException(
+ 'Cannot delete folder because it is not empty',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.cannotDeleteEmptyFolder',
+ },
+ }
+ );
+ }
+ }
+
+ await this.prismaService.$tx(async (prisma) => {
+ await this.deleteResource(
+ baseId,
+ node.resourceType as BaseNodeResourceType,
+ node.resourceId,
+ permanent
+ );
+ await prisma.baseNode.delete({
+ where: { id: nodeId },
+ });
+ });
+
+ this.presenceHandler(baseId, (presence) => {
+ presence.submit({
+ event: 'delete',
+ data: { id: nodeId },
+ });
+ });
+ }
+
+ protected async deleteResource(
+ baseId: string,
+ type: BaseNodeResourceType,
+ id: string,
+ permanent?: boolean
+ ) {
+ switch (type) {
+ case BaseNodeResourceType.Folder:
+ await this.baseNodeFolderService.deleteFolder(baseId, id);
+ break;
+ case BaseNodeResourceType.Table:
+ if (permanent) {
+ await this.tableOpenApiService.permanentDeleteTables(baseId, [id]);
+ } else {
+ await this.tableOpenApiService.deleteTable(baseId, id);
+ }
+ break;
+ case BaseNodeResourceType.Dashboard:
+ await this.dashboardService.deleteDashboard(baseId, id);
+ break;
+ default:
+ throw new CustomHttpException(
+ `Invalid resource type ${type}`,
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.invalidResourceType',
+ },
+ }
+ );
+ }
+ }
+
+ async move(baseId: string, nodeId: string, ro: IMoveBaseNodeRo): Promise {
+ const { parentId, anchorId, position } = ro;
+
+ const node = await this.prismaService.baseNode
+ .findFirstOrThrow({
+ where: { baseId, id: nodeId },
+ })
+ .catch(() => {
+ throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.notFound',
+ },
+ });
+ });
+
+ if (isString(parentId) && isString(anchorId)) {
+ throw new CustomHttpException(
+ 'Only one of parentId or anchorId must be provided',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.onlyOneOfParentIdOrAnchorIdRequired',
+ },
+ }
+ );
+ }
+
+ if (parentId === nodeId) {
+ throw new CustomHttpException('Cannot move node to itself', HttpErrorCode.VALIDATION_ERROR, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.cannotMoveToItself',
+ },
+ });
+ }
+
+ if (anchorId === nodeId) {
+ throw new CustomHttpException(
+ 'Cannot move node to its own child (circular reference)',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.cannotMoveToCircularReference',
+ },
+ }
+ );
+ }
+
+ let newNode: IBaseNodeEntry;
+ if (anchorId) {
+ newNode = await this.moveNodeTo(baseId, node.id, { anchorId, position });
+ } else if (parentId === null) {
+ newNode = await this.moveNodeToRoot(baseId, node.id);
+ } else if (parentId) {
+ newNode = await this.moveNodeToFolder(baseId, node.id, parentId);
+ } else {
+ throw new CustomHttpException(
+ 'At least one of parentId or anchorId must be provided',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.anchorIdOrParentIdRequired',
+ },
+ }
+ );
+ }
+
+ const vo = await this.entry2vo(newNode);
+ this.presenceHandler(baseId, (presence) => {
+ presence.submit({
+ event: 'update',
+ data: { ...vo },
+ });
+ });
+
+ return vo;
+ }
+
+ private async moveNodeToRoot(baseId: string, nodeId: string) {
+ return this.prismaService.$tx(async (prisma) => {
+ const maxOrder = await this.getMaxOrder(baseId);
+ return prisma.baseNode.update({
+ where: { id: nodeId },
+ select: this.getSelect(),
+ data: {
+ parentId: null,
+ order: maxOrder + 1,
+ lastModifiedBy: this.userId,
+ },
+ });
+ });
+ }
+
+ private async moveNodeToFolder(baseId: string, nodeId: string, parentId: string) {
+ return this.prismaService.$tx(async (prisma) => {
+ const node = await prisma.baseNode
+ .findFirstOrThrow({
+ where: { baseId, id: nodeId },
+ })
+ .catch(() => {
+ throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.notFound',
+ },
+ });
+ });
+
+ const parentNode = await prisma.baseNode
+ .findFirstOrThrow({
+ where: { baseId, id: parentId },
+ })
+ .catch(() => {
+ throw new CustomHttpException(`Parent ${parentId} not found`, HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.parentNotFound',
+ },
+ });
+ });
+
+ if (parentNode.resourceType !== BaseNodeResourceType.Folder) {
+ throw new CustomHttpException(
+ `Parent ${parentId} is not a folder`,
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.parentIsNotFolder',
+ },
+ }
+ );
+ }
+
+ if (node.resourceType === BaseNodeResourceType.Folder && parentId) {
+ await this.assertFolderDepth(baseId, parentId);
+ }
+
+ // Check for circular reference
+ const isCircular = await this.isCircularReference(baseId, nodeId, parentId);
+ if (isCircular) {
+ throw new CustomHttpException(
+ 'Cannot move node to its own child (circular reference)',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.circularReference',
+ },
+ }
+ );
+ }
+
+ const maxOrder = await this.getMaxOrder(baseId);
+ return prisma.baseNode.update({
+ where: { id: nodeId },
+ select: this.getSelect(),
+ data: {
+ parentId,
+ order: maxOrder + 1,
+ lastModifiedBy: this.userId,
+ },
+ });
+ });
+ }
+
+ private async moveNodeTo(
+ baseId: string,
+ nodeId: string,
+ ro: Pick
+ ): Promise {
+ const { anchorId, position } = ro;
+ return this.prismaService.$tx(async (prisma) => {
+ const node = await prisma.baseNode
+ .findFirstOrThrow({
+ where: { baseId, id: nodeId },
+ })
+ .catch(() => {
+ throw new CustomHttpException(`Node ${nodeId} not found`, HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.notFound',
+ },
+ });
+ });
+
+ const anchor = await prisma.baseNode
+ .findFirstOrThrow({
+ where: { baseId, id: anchorId },
+ })
+ .catch(() => {
+ throw new CustomHttpException(`Anchor ${anchorId} not found`, HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.anchorNotFound',
+ },
+ });
+ });
+
+ if (node.resourceType === BaseNodeResourceType.Folder && anchor.parentId) {
+ await this.assertFolderDepth(baseId, anchor.parentId);
+ }
+
+ await updateOrder({
+ query: baseId,
+ position: position ?? 'after',
+ item: node,
+ anchorItem: anchor,
+ getNextItem: async (whereOrder, align) => {
+ return prisma.baseNode.findFirst({
+ where: {
+ baseId,
+ parentId: anchor.parentId,
+ order: whereOrder,
+ },
+ select: { order: true, id: true },
+ orderBy: { order: align },
+ });
+ },
+ update: async (_, id, data) => {
+ await prisma.baseNode.update({
+ where: { id },
+ data: { parentId: anchor.parentId, order: data.newOrder },
+ });
+ },
+ shuffle: async () => {
+ await this.shuffleOrders(baseId, anchor.parentId);
+ },
+ });
+
+ return prisma.baseNode.findFirstOrThrow({
+ where: { baseId, id: nodeId },
+ select: this.getSelect(),
+ });
+ });
+ }
+
+ @OnEvent(Events.BASE_FOLDER_CREATE)
+ @OnEvent(Events.TABLE_CREATE)
+ @OnEvent(Events.DASHBOARD_CREATE)
+ @OnEvent(Events.WORKFLOW_CREATE)
+ @OnEvent(Events.APP_CREATE)
+ async onResourceCreate(event: IResourceCreateEvent) {
+ const { baseId, resourceType, resourceId, userId } = this.prepareResourceCreate(event);
+
+ if (!baseId || !resourceType || !resourceId) {
+ this.logger.error('Invalid resource create event', event);
+ return;
+ }
+
+ const createNode = async (prisma: PrismaService) => {
+ const findNode = await prisma.baseNode.findFirst({
+ where: { baseId, resourceType, resourceId },
+ });
+ if (findNode) {
+ return;
+ }
+ const maxOrder = await this.getMaxOrder(baseId);
+ await prisma.baseNode.create({
+ data: {
+ id: generateBaseNodeId(),
+ baseId,
+ resourceType,
+ resourceId,
+ parentId: null,
+ order: maxOrder + 1,
+ createdBy: userId || ANONYMOUS_USER_ID,
+ },
+ });
+ };
+ await createNode(this.prismaService);
+
+ this.presenceHandler(baseId, (presence) => {
+ presence.submit({
+ event: 'flush',
+ });
+ });
+ }
+
+ private prepareResourceCreate(event: IResourceCreateEvent) {
+ let baseId: string;
+ let resourceType: BaseNodeResourceType | undefined;
+ let resourceId: string | undefined;
+ let name: string | undefined;
+ let icon: string | undefined;
+ switch (event.name) {
+ case Events.BASE_FOLDER_CREATE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Folder;
+ resourceId = event.payload.folder.id;
+ name = event.payload.folder.name;
+ break;
+ case Events.TABLE_CREATE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Table;
+ // get the table id from the table op
+ resourceId = (event.payload.table as unknown as { id: string }).id;
+ name = event.payload.table.name;
+ icon = event.payload.table.icon;
+ break;
+ case Events.WORKFLOW_CREATE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Workflow;
+ resourceId = event.payload.workflow.id;
+ name = event.payload.workflow.name;
+ break;
+ case Events.DASHBOARD_CREATE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Dashboard;
+ resourceId = event.payload.dashboard.id;
+ name = event.payload.dashboard.name;
+ break;
+ case Events.APP_CREATE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.App;
+ resourceId = event.payload.app.id;
+ name = event.payload.app.name;
+ break;
+ }
+ return {
+ baseId,
+ resourceType,
+ resourceId,
+ name,
+ icon,
+ userId: event.context.user?.id,
+ };
+ }
+
+ @OnEvent(Events.BASE_FOLDER_UPDATE)
+ @OnEvent(Events.TABLE_UPDATE)
+ @OnEvent(Events.DASHBOARD_UPDATE)
+ @OnEvent(Events.WORKFLOW_UPDATE)
+ @OnEvent(Events.APP_UPDATE)
+ async onResourceUpdate(event: IResourceUpdateEvent) {
+ const { baseId, resourceType, resourceId } = this.prepareResourceUpdate(event);
+ if (baseId && resourceType && resourceId) {
+ this.presenceHandler(baseId, (presence) => {
+ presence.submit({
+ event: 'flush',
+ });
+ });
+ }
+ }
+
+ private prepareResourceUpdate(event: IResourceUpdateEvent) {
+ let baseId: string;
+ let resourceType: BaseNodeResourceType | undefined;
+ let resourceId: string | undefined;
+ let name: string | undefined;
+ let icon: string | undefined;
+ switch (event.name) {
+ case Events.TABLE_UPDATE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Table;
+ resourceId = event.payload.table.id;
+ name = event.payload.table?.name?.newValue as string;
+ icon = event.payload.table?.icon?.newValue as string;
+ break;
+ case Events.WORKFLOW_UPDATE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Workflow;
+ resourceId = event.payload.workflow.id;
+ name = event.payload.workflow.name;
+ break;
+ case Events.DASHBOARD_UPDATE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Dashboard;
+ resourceId = event.payload.dashboard.id;
+ name = event.payload.dashboard.name;
+ break;
+ case Events.APP_UPDATE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.App;
+ resourceId = event.payload.app.id;
+ name = event.payload.app.name;
+ break;
+ case Events.BASE_FOLDER_UPDATE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Folder;
+ resourceId = event.payload.folder.id;
+ name = event.payload.folder.name;
+ break;
+ }
+ return {
+ baseId,
+ resourceType,
+ resourceId,
+ name,
+ icon,
+ };
+ }
+
+ @OnEvent(Events.BASE_DELETE)
+ @OnEvent(Events.BASE_FOLDER_DELETE)
+ @OnEvent(Events.TABLE_DELETE)
+ @OnEvent(Events.DASHBOARD_DELETE)
+ @OnEvent(Events.WORKFLOW_DELETE)
+ @OnEvent(Events.APP_DELETE)
+ async onResourceDelete(event: IResourceDeleteEvent) {
+ const { baseId, resourceType, resourceId } = this.prepareResourceDelete(event);
+ if (!baseId) {
+ return;
+ }
+ if (event.name === Events.BASE_DELETE) {
+ await this.prismaService.baseNode.deleteMany({
+ where: { baseId },
+ });
+ return;
+ }
+ if (!resourceType || !resourceId) {
+ this.logger.error('Invalid resource delete event', event);
+ return;
+ }
+
+ const deleteNode = async (prisma: Prisma.TransactionClient) => {
+ const toDeleteNode = await prisma.baseNode.findFirst({
+ where: { baseId, resourceType, resourceId },
+ });
+ if (!toDeleteNode) {
+ return;
+ }
+ await prisma.baseNode.deleteMany({
+ where: { id: toDeleteNode.id },
+ });
+ const maxOrder = await this.getMaxOrder(baseId);
+ const orphans = await prisma.baseNode.findMany({
+ where: { baseId, parentId: toDeleteNode.parentId },
+ select: { id: true, order: true },
+ });
+ if (orphans.length > 0) {
+ await this.batchUpdateBaseNodes(
+ orphans.map((orphan) => ({
+ id: orphan.id,
+ values: {
+ parentId: null,
+ order: maxOrder + orphan.order + 1,
+ },
+ }))
+ );
+ }
+ };
+ await deleteNode(this.prismaService);
+
+ this.presenceHandler(baseId, (presence) => {
+ presence.submit({
+ event: 'flush',
+ });
+ });
+ }
+
+ private prepareResourceDelete(event: IResourceDeleteEvent) {
+ let baseId: string;
+ let resourceType: BaseNodeResourceType | undefined;
+ let resourceId: string | undefined;
+ switch (event.name) {
+ case Events.BASE_DELETE:
+ baseId = event.payload.baseId;
+ break;
+ case Events.TABLE_DELETE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Table;
+ resourceId = event.payload.tableId;
+ break;
+ case Events.WORKFLOW_DELETE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Workflow;
+ resourceId = event.payload.workflowId;
+ break;
+ case Events.DASHBOARD_DELETE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Dashboard;
+ resourceId = event.payload.dashboardId;
+ break;
+ case Events.APP_DELETE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.App;
+ resourceId = event.payload.appId;
+ break;
+ case Events.BASE_FOLDER_DELETE:
+ baseId = event.payload.baseId;
+ resourceType = BaseNodeResourceType.Folder;
+ resourceId = event.payload.folderId;
+ break;
+ }
+ return {
+ baseId,
+ resourceType,
+ resourceId,
+ };
+ }
+
+ private async getMaxOrder(baseId: string, parentId?: string | null) {
+ const prisma = this.prismaService.txClient();
+ const aggregate = await prisma.baseNode.aggregate({
+ where: { baseId, parentId },
+ _max: { order: true },
+ });
+
+ return aggregate._max.order ?? 0;
+ }
+
+ private async shuffleOrders(baseId: string, parentId: string | null) {
+ const prisma = this.prismaService.txClient();
+ const siblings = await prisma.baseNode.findMany({
+ where: { baseId, parentId },
+ orderBy: { order: 'asc' },
+ });
+
+ for (const [index, sibling] of siblings.entries()) {
+ await prisma.baseNode.update({
+ where: { id: sibling.id },
+ data: { order: index + 10, lastModifiedBy: this.userId },
+ });
+ }
+ }
+
+ private async getParentNodeOrThrow(id: string) {
+ const entry = await this.prismaService.baseNode.findFirst({
+ where: { id },
+ select: {
+ id: true,
+ parentId: true,
+ resourceType: true,
+ resourceId: true,
+ },
+ });
+ if (!entry) {
+ throw new CustomHttpException('Base node not found', HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.notFound',
+ },
+ });
+ }
+ return entry;
+ }
+
+ private async assertFolderDepth(baseId: string, id: string) {
+ const folderDepth = await this.getFolderDepth(baseId, id);
+ if (folderDepth >= maxFolderDepth) {
+ throw new CustomHttpException('Folder depth limit exceeded', HttpErrorCode.VALIDATION_ERROR, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.folderDepthLimitExceeded',
+ },
+ });
+ }
+ }
+
+ private async getFolderDepth(baseId: string, id: string) {
+ const prisma = this.prismaService.txClient();
+ const allFolders = await prisma.baseNode.findMany({
+ where: { baseId, resourceType: BaseNodeResourceType.Folder },
+ select: { id: true, parentId: true },
+ });
+
+ let depth = 0;
+ if (allFolders.length === 0) {
+ return depth;
+ }
+
+ const folderMap = keyBy(allFolders, 'id');
+ let current = id;
+ while (current) {
+ depth++;
+ const folder = folderMap[current];
+ if (!folder) {
+ throw new CustomHttpException('Folder not found', HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.folderNotFound',
+ },
+ });
+ }
+ if (folder.parentId === id) {
+ throw new CustomHttpException(
+ 'A folder cannot be its own parent',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.circularReference',
+ },
+ }
+ );
+ }
+ current = folder.parentId ?? '';
+ }
+ return depth;
+ }
+
+ private async isCircularReference(
+ baseId: string,
+ nodeId: string,
+ parentId: string
+ ): Promise {
+ const knex = this.knex;
+
+ // Non-recursive query: Start with the parent node
+ const nonRecursiveQuery = knex
+ .select('id', 'parent_id', 'base_id')
+ .from('base_node')
+ .where('id', parentId)
+ .andWhere('base_id', baseId);
+
+ // Recursive query: Traverse up the parent chain
+ const recursiveQuery = knex
+ .select('bn.id', 'bn.parent_id', 'bn.base_id')
+ .from('base_node as bn')
+ .innerJoin('ancestors as a', function () {
+ // Join condition: bn.id = a.parent_id (get parent of current ancestor)
+ this.on('bn.id', '=', 'a.parent_id').andOn('bn.base_id', '=', knex.raw('?', [baseId]));
+ });
+
+ // Combine non-recursive and recursive queries
+ const cteQuery = nonRecursiveQuery.union(recursiveQuery);
+
+ // Build final query with recursive CTE
+ const finalQuery = knex
+ .withRecursive('ancestors', ['id', 'parent_id', 'base_id'], cteQuery)
+ .select('id')
+ .from('ancestors')
+ .where('id', nodeId)
+ .limit(1)
+ .toQuery();
+
+ // Execute query
+ const result = await this.prismaService
+ .txClient()
+ .$queryRawUnsafe>(finalQuery);
+
+ return result.length > 0;
+ }
+
+ private async batchUpdateBaseNodes(data: { id: string; values: { [key: string]: unknown } }[]) {
+ const sql = this.buildBatchUpdateSql(data);
+ if (!sql) {
+ return;
+ }
+ await this.prismaService.txClient().$executeRawUnsafe(sql);
+ }
+
+ buildBatchUpdateSql(data: { id: string; values: { [key: string]: unknown } }[]): string | null {
+ if (data.length === 0) {
+ return null;
+ }
+
+ const caseStatements: Record = {};
+ for (const { id, values } of data) {
+ for (const [key, value] of Object.entries(values)) {
+ if (!caseStatements[key]) {
+ caseStatements[key] = [];
+ }
+ caseStatements[key].push({ when: id, then: value });
+ }
+ }
+
+ const updatePayload: Record = {};
+ for (const [key, statements] of Object.entries(caseStatements)) {
+ if (statements.length === 0) {
+ continue;
+ }
+ const column = snakeCase(key);
+ const whenClauses: string[] = [];
+ const caseBindings: unknown[] = [];
+ for (const { when, then } of statements) {
+ whenClauses.push('WHEN ?? = ? THEN ?');
+ caseBindings.push('id', when, then);
+ }
+ const caseExpression = `CASE ${whenClauses.join(' ')} ELSE ?? END`;
+ const rawExpression = this.knex.raw(caseExpression, [...caseBindings, column]);
+ updatePayload[column] = rawExpression;
+ }
+
+ if (Object.keys(updatePayload).length === 0) {
+ return null;
+ }
+
+ const idsToUpdate = data.map((item) => item.id);
+ return this.knex('base_node').update(updatePayload).whereIn('id', idsToUpdate).toQuery();
+ }
+}
diff --git a/apps/nestjs-backend/src/features/base-node/folder/base-node-folder.controller.ts b/apps/nestjs-backend/src/features/base-node/folder/base-node-folder.controller.ts
new file mode 100644
index 0000000000..69bf0ff8d8
--- /dev/null
+++ b/apps/nestjs-backend/src/features/base-node/folder/base-node-folder.controller.ts
@@ -0,0 +1,47 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import { Controller, Post, Patch, Delete, Param, Body } from '@nestjs/common';
+import type { ICreateBaseNodeFolderVo, IUpdateBaseNodeFolderVo } from '@teable/openapi';
+import {
+ createBaseNodeFolderRoSchema,
+ ICreateBaseNodeFolderRo,
+ updateBaseNodeFolderRoSchema,
+ IUpdateBaseNodeFolderRo,
+} from '@teable/openapi';
+import { EmitControllerEvent } from '../../../event-emitter/decorators/emit-controller-event.decorator';
+import { Events } from '../../../event-emitter/events';
+import { ZodValidationPipe } from '../../../zod.validation.pipe';
+import { Permissions } from '../../auth/decorators/permissions.decorator';
+import { BaseNodeFolderService } from './base-node-folder.service';
+
+@Controller('api/base/:baseId/node/folder')
+export class BaseNodeFolderController {
+ constructor(private readonly baseNodeFolderService: BaseNodeFolderService) {}
+
+ @Post()
+ @Permissions('base|update')
+ @EmitControllerEvent(Events.BASE_FOLDER_CREATE)
+ async createFolder(
+ @Param('baseId') baseId: string,
+ @Body(new ZodValidationPipe(createBaseNodeFolderRoSchema)) ro: ICreateBaseNodeFolderRo
+ ): Promise {
+ return this.baseNodeFolderService.createFolder(baseId, ro);
+ }
+
+ @Patch(':folderId')
+ @Permissions('base|update')
+ @EmitControllerEvent(Events.BASE_FOLDER_UPDATE)
+ async renameFolder(
+ @Param('baseId') baseId: string,
+ @Param('folderId') folderId: string,
+ @Body(new ZodValidationPipe(updateBaseNodeFolderRoSchema)) ro: IUpdateBaseNodeFolderRo
+ ): Promise {
+ return this.baseNodeFolderService.renameFolder(baseId, folderId, ro);
+ }
+
+ @Delete(':folderId')
+ @Permissions('base|update')
+ @EmitControllerEvent(Events.BASE_FOLDER_DELETE)
+ async deleteFolder(@Param('baseId') baseId: string, @Param('folderId') folderId: string) {
+ return this.baseNodeFolderService.deleteFolder(baseId, folderId);
+ }
+}
diff --git a/apps/nestjs-backend/src/features/base-node/folder/base-node-folder.module.ts b/apps/nestjs-backend/src/features/base-node/folder/base-node-folder.module.ts
new file mode 100644
index 0000000000..235d680dc3
--- /dev/null
+++ b/apps/nestjs-backend/src/features/base-node/folder/base-node-folder.module.ts
@@ -0,0 +1,11 @@
+import { Module } from '@nestjs/common';
+import { BaseNodeFolderController } from './base-node-folder.controller';
+import { BaseNodeFolderService } from './base-node-folder.service';
+
+@Module({
+ imports: [],
+ providers: [BaseNodeFolderService],
+ exports: [BaseNodeFolderService],
+ controllers: [BaseNodeFolderController],
+})
+export class BaseNodeFolderModule {}
diff --git a/apps/nestjs-backend/src/features/base-node/folder/base-node-folder.service.ts b/apps/nestjs-backend/src/features/base-node/folder/base-node-folder.service.ts
new file mode 100644
index 0000000000..6929e5f0bc
--- /dev/null
+++ b/apps/nestjs-backend/src/features/base-node/folder/base-node-folder.service.ts
@@ -0,0 +1,82 @@
+import { Logger, Injectable } from '@nestjs/common';
+import { generateBaseNodeFolderId, getUniqName, HttpErrorCode } from '@teable/core';
+import { PrismaService } from '@teable/db-main-prisma';
+import type { ICreateBaseNodeFolderRo, IUpdateBaseNodeFolderRo } from '@teable/openapi';
+import { ClsService } from 'nestjs-cls';
+import { CustomHttpException } from '../../../custom.exception';
+import type { IClsStore } from '../../../types/cls';
+
+@Injectable()
+export class BaseNodeFolderService {
+ private readonly logger = new Logger(BaseNodeFolderService.name);
+ constructor(
+ private readonly prismaService: PrismaService,
+ private readonly cls: ClsService
+ ) {}
+
+ private get userId() {
+ return this.cls.get('user.id');
+ }
+
+ async createFolder(baseId: string, ro: ICreateBaseNodeFolderRo) {
+ const { name } = ro;
+ const uniqueName = await this.getUniqueName(baseId, name);
+ return this.prismaService.txClient().baseNodeFolder.create({
+ data: {
+ id: generateBaseNodeFolderId(),
+ baseId,
+ name: uniqueName,
+ createdBy: this.userId,
+ },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+ }
+
+ async renameFolder(baseId: string, folderId: string, body: IUpdateBaseNodeFolderRo) {
+ const { name } = body;
+
+ return this.prismaService.$tx(async (prisma) => {
+ const find = await prisma.baseNodeFolder.findFirst({
+ where: { baseId, name, id: { not: folderId } },
+ });
+ if (find) {
+ throw new CustomHttpException(
+ 'Folder name already exists',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.baseNode.nameAlreadyExists',
+ },
+ }
+ );
+ }
+
+ return prisma.baseNodeFolder.update({
+ where: { id: folderId },
+ data: { name, lastModifiedBy: this.userId },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+ });
+ }
+
+ async deleteFolder(baseId: string, folderId: string) {
+ await this.prismaService.txClient().baseNodeFolder.delete({
+ where: { baseId, id: folderId },
+ });
+ }
+
+ private async getUniqueName(baseId: string, name: string) {
+ const list = await this.prismaService.baseNodeFolder.findMany({
+ where: { baseId },
+ select: { name: true },
+ });
+ const names = list.map((item) => item.name);
+ return getUniqName(name, names);
+ }
+}
diff --git a/apps/nestjs-backend/src/features/base/base-export.service.ts b/apps/nestjs-backend/src/features/base/base-export.service.ts
index ffe9e124fa..d6f078cad9 100644
--- a/apps/nestjs-backend/src/features/base/base-export.service.ts
+++ b/apps/nestjs-backend/src/features/base/base-export.service.ts
@@ -6,7 +6,7 @@ import { FieldType, getRandomString, ViewType, isLinkLookupOptions } from '@teab
import type { Field, View, TableMeta, Base } from '@teable/db-main-prisma';
import { PrismaService } from '@teable/db-main-prisma';
import { PluginPosition, UploadType } from '@teable/openapi';
-import type { IBaseJson } from '@teable/openapi';
+import type { BaseNodeResourceType, IBaseJson } from '@teable/openapi';
import archiver from 'archiver';
import { stringify } from 'csv-stringify/sync';
import { Knex } from 'knex';
@@ -302,6 +302,8 @@ export class BaseExportService {
}
const plugins = await this.generatePluginConfig(baseId);
+ const folders = await this.generateFolderConfig(baseId);
+ const nodes = await this.generateNodeConfig(baseId);
return {
name: baseName,
@@ -309,6 +311,8 @@ export class BaseExportService {
version: process.env.NEXT_PUBLIC_BUILD_VERSION!,
tables,
plugins,
+ folders,
+ nodes,
};
}
@@ -820,6 +824,54 @@ export class BaseExportService {
);
}
+ async generateFolderConfig(baseId: string): Promise {
+ const prisma = this.prismaService.txClient();
+ const folderRaws = await prisma.baseNodeFolder.findMany({
+ where: {
+ baseId,
+ },
+ orderBy: {
+ createdTime: 'asc',
+ },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+
+ return folderRaws.map((folderRaw) => ({
+ id: folderRaw.id,
+ name: folderRaw.name,
+ }));
+ }
+
+ async generateNodeConfig(baseId: string): Promise {
+ const prisma = this.prismaService.txClient();
+ const nodeRaws = await prisma.baseNode.findMany({
+ where: {
+ baseId,
+ },
+ orderBy: {
+ createdTime: 'asc',
+ },
+ select: {
+ id: true,
+ parentId: true,
+ resourceId: true,
+ resourceType: true,
+ order: true,
+ },
+ });
+
+ return nodeRaws.map((nodeRaw) => ({
+ id: nodeRaw.id,
+ parentId: nodeRaw.parentId,
+ resourceId: nodeRaw.resourceId,
+ resourceType: nodeRaw.resourceType as BaseNodeResourceType,
+ order: nodeRaw.order,
+ }));
+ }
+
async generatePluginConfig(baseId: string) {
const pluginJson = {} as IBaseJson['plugins'];
diff --git a/apps/nestjs-backend/src/features/base/base-import.service.ts b/apps/nestjs-backend/src/features/base/base-import.service.ts
index 9b3338881b..a7209138ec 100644
--- a/apps/nestjs-backend/src/features/base/base-import.service.ts
+++ b/apps/nestjs-backend/src/features/base/base-import.service.ts
@@ -4,6 +4,8 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import {
FieldType,
generateBaseId,
+ generateBaseNodeFolderId,
+ generateBaseNodeId,
generateDashboardId,
generateLogId,
generatePluginInstallId,
@@ -12,7 +14,7 @@ import {
ViewType,
} from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
-import { UploadType, PluginPosition } from '@teable/openapi';
+import { UploadType, PluginPosition, BaseNodeResourceType } from '@teable/openapi';
import type {
ICreateBaseVo,
IBaseJson,
@@ -241,8 +243,13 @@ export class BaseImportService {
return logId;
}
- async createBaseStructure(spaceId: string, structure: IBaseJson, baseId?: string) {
- const { name, icon, tables, plugins } = structure;
+ async createBaseStructure(
+ spaceId: string,
+ structure: IBaseJson,
+ baseId?: string,
+ skipCreateBaseNodes?: boolean
+ ) {
+ const { name, icon, tables, plugins, folders } = structure;
// create base
const newBase = baseId
@@ -277,9 +284,28 @@ export class BaseImportService {
this.logger.log(`base-duplicate-service: Duplicate base tables successfully`);
// create plugins
- await this.createPlugins(newBase.id, plugins, tableIdMap, fieldIdMap, viewIdMap);
+ const { dashboardIdMap } = await this.createPlugins(
+ newBase.id,
+ plugins,
+ tableIdMap,
+ fieldIdMap,
+ viewIdMap
+ );
this.logger.log(`base-duplicate-service: Duplicate base plugins successfully`);
+ // create folders
+ const { folderIdMap } = await this.createFolders(newBase.id, folders);
+ this.logger.log(`base-duplicate-service: Duplicate base folders successfully`);
+
+ // create base nodes
+ if (!skipCreateBaseNodes) {
+ await this.createBaseNodes(newBase.id, structure.nodes, {
+ folderIdMap,
+ tableIdMap,
+ dashboardIdMap,
+ });
+ }
+
return {
base: newBase,
tableIdMap,
@@ -287,6 +313,8 @@ export class BaseImportService {
viewIdMap,
structure,
fkMap,
+ folderIdMap,
+ dashboardIdMap,
};
}
@@ -453,6 +481,128 @@ export class BaseImportService {
return viewMap;
}
+ private async createFolders(baseId: string, folders: IBaseJson['folders']) {
+ const prisma = this.prismaService.txClient();
+ const userId = this.cls.get('user.id');
+ const folderIdMap: Record = {};
+ for (const folder of folders) {
+ const { id, name } = folder;
+ const newFolderId = generateBaseNodeFolderId();
+ await prisma.baseNodeFolder.create({
+ data: { id: newFolderId, name, baseId, createdBy: userId },
+ });
+ folderIdMap[id] = newFolderId;
+ }
+ return { folderIdMap };
+ }
+
+ async createBaseNodes(
+ baseId: string,
+ nodes: IBaseJson['nodes'],
+ idMapContext: {
+ folderIdMap?: Record;
+ tableIdMap?: Record;
+ dashboardIdMap?: Record;
+ workflowIdMap?: Record;
+ appIdMap?: Record;
+ }
+ ) {
+ if (!nodes || nodes.length === 0) {
+ return;
+ }
+
+ const prisma = this.prismaService.txClient();
+ const userId = this.cls.get('user.id');
+ const {
+ folderIdMap = {},
+ tableIdMap = {},
+ dashboardIdMap = {},
+ workflowIdMap = {},
+ appIdMap = {},
+ } = idMapContext;
+
+ const allNodeIdMap = nodes.reduce(
+ (acc, cur) => {
+ acc[cur.id] = generateBaseNodeId();
+ return acc;
+ },
+ {} as Record
+ );
+
+ const allTypeNodeIdMap = nodes.reduce(
+ (acc, cur) => {
+ const { resourceType, resourceId } = cur;
+ acc[resourceType] = acc[resourceType] ?? {};
+ switch (resourceType) {
+ case BaseNodeResourceType.Folder:
+ acc[resourceType][resourceId] = folderIdMap[resourceId];
+ break;
+ case BaseNodeResourceType.Table:
+ acc[resourceType][resourceId] = tableIdMap[resourceId];
+ break;
+ case BaseNodeResourceType.Dashboard:
+ acc[resourceType][resourceId] = dashboardIdMap[resourceId];
+ break;
+ case BaseNodeResourceType.Workflow:
+ acc[resourceType][resourceId] = workflowIdMap[resourceId];
+ break;
+ case BaseNodeResourceType.App:
+ acc[resourceType][resourceId] = appIdMap[resourceId];
+ break;
+ default:
+ break;
+ }
+ return acc;
+ },
+ {} as Record>
+ );
+ // Sort nodes by parent-child relationship (topological sort)
+ // Ensure parent nodes are created before child nodes
+ const sortedNodes: typeof nodes = [];
+ const nodeMap = new Map(nodes.map((node) => [node.id, node]));
+ const visited = new Set();
+
+ const visit = (node: (typeof nodes)[0]) => {
+ if (visited.has(node.id)) return;
+ if (node.parentId && nodeMap.has(node.parentId)) {
+ visit(nodeMap.get(node.parentId)!);
+ }
+ visited.add(node.id);
+ sortedNodes.push(node);
+ };
+
+ for (const node of nodes) {
+ visit(node);
+ }
+
+ for (const node of sortedNodes) {
+ const { id, parentId, resourceId, resourceType, order } = node;
+ const newId = allNodeIdMap[id];
+ const newParentId = parentId && allNodeIdMap[parentId] ? allNodeIdMap[parentId] : null;
+ const newResourceId =
+ allTypeNodeIdMap[resourceType] && allTypeNodeIdMap[resourceType][resourceId]
+ ? allTypeNodeIdMap[resourceType][resourceId]
+ : null;
+ if (!newResourceId) {
+ this.logger.error(
+ `base-import-service: create base node failed, nodeId: ${id}, resourceId: ${resourceId}, resourceType: ${resourceType}`
+ );
+ continue;
+ }
+ await prisma.baseNode.create({
+ data: {
+ id: newId,
+ parentId: newParentId,
+ resourceId: newResourceId,
+ resourceType,
+ baseId,
+ createdBy: userId,
+ order,
+ },
+ });
+ }
+ }
+
private async createPlugins(
baseId: string,
plugins: IBaseJson['plugins'],
@@ -460,7 +610,12 @@ export class BaseImportService {
fieldMap: Record,
viewIdMap: Record
) {
- await this.createDashboard(baseId, plugins[PluginPosition.Dashboard], tableIdMap, fieldMap);
+ const { dashboardIdMap } = await this.createDashboard(
+ baseId,
+ plugins[PluginPosition.Dashboard],
+ tableIdMap,
+ fieldMap
+ );
await this.createPanel(baseId, plugins[PluginPosition.Panel], tableIdMap, fieldMap);
await this.createPluginViews(
baseId,
@@ -469,6 +624,7 @@ export class BaseImportService {
fieldMap,
viewIdMap
);
+ return { dashboardIdMap };
}
async createDashboard(
@@ -529,7 +685,7 @@ export class BaseImportService {
}
return {
- dashboardMap,
+ dashboardIdMap: dashboardMap,
};
}
diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts
index 797203f76b..017dbc3970 100644
--- a/apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts
+++ b/apps/nestjs-backend/src/features/dashboard/dashboard.controller.ts
@@ -26,6 +26,8 @@ import type {
IDashboardPluginUpdateStorageVo,
IGetDashboardInstallPluginVo,
} from '@teable/openapi';
+import { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';
+import { Events } from '../../event-emitter/events';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { DashboardService } from './dashboard.service';
@@ -51,6 +53,7 @@ export class DashboardController {
@Post()
@Permissions('base|update')
+ @EmitControllerEvent(Events.DASHBOARD_CREATE)
createDashboard(
@Param('baseId') baseId: string,
@Body(new ZodValidationPipe(createDashboardRoSchema)) ro: ICreateDashboardRo
@@ -60,6 +63,7 @@ export class DashboardController {
@Patch(':id/rename')
@Permissions('base|update')
+ @EmitControllerEvent(Events.DASHBOARD_UPDATE)
updateDashboard(
@Param('baseId') baseId: string,
@Param('id') id: string,
@@ -80,12 +84,14 @@ export class DashboardController {
@Delete(':id')
@Permissions('base|update')
+ @EmitControllerEvent(Events.DASHBOARD_DELETE)
deleteDashboard(@Param('baseId') baseId: string, @Param('id') id: string): Promise {
return this.dashboardService.deleteDashboard(baseId, id);
}
@Post(':id/duplicate')
@Permissions('base|update')
+ @EmitControllerEvent(Events.DASHBOARD_CREATE)
duplicateDashboard(
@Param('baseId') baseId: string,
@Param('id') id: string,
diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts
index f59c9106d7..c96ad55f07 100644
--- a/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts
+++ b/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts
@@ -110,7 +110,7 @@ export class DashboardService {
async createDashboard(baseId: string, dashboard: ICreateDashboardRo) {
const userId = this.cls.get('user.id');
- return this.prismaService.dashboard.create({
+ return this.prismaService.txClient().dashboard.create({
data: {
id: generateDashboardId(),
baseId,
@@ -125,8 +125,9 @@ export class DashboardService {
}
async renameDashboard(baseId: string, id: string, name: string) {
- return this.prismaService.dashboard
- .update({
+ return this.prismaService
+ .txClient()
+ .dashboard.update({
where: {
baseId,
id,
@@ -178,8 +179,9 @@ export class DashboardService {
}
async deleteDashboard(baseId: string, id: string) {
- await this.prismaService.dashboard
- .delete({
+ await this.prismaService
+ .txClient()
+ .dashboard.delete({
where: {
baseId,
id,
@@ -525,14 +527,14 @@ export class DashboardService {
dashboard.name = newName;
return this.prismaService.$tx(async () => {
- const { dashboardMap } = await this.baseImportService.createDashboard(
+ const { dashboardIdMap } = await this.baseImportService.createDashboard(
baseId,
[dashboard],
{},
{}
);
- const newDashboardId = dashboardMap[dashboardId];
+ const newDashboardId = dashboardIdMap[dashboardId];
return {
id: newDashboardId,
diff --git a/apps/nestjs-backend/src/features/mail-sender/mail-sender.service.ts b/apps/nestjs-backend/src/features/mail-sender/mail-sender.service.ts
index 2fbc16d70a..61ad06616f 100644
--- a/apps/nestjs-backend/src/features/mail-sender/mail-sender.service.ts
+++ b/apps/nestjs-backend/src/features/mail-sender/mail-sender.service.ts
@@ -248,7 +248,7 @@ export class MailSenderService {
let subject, partialBody;
const refLength = recordIds.length;
- const viewRecordUrlPrefix = `${this.mailConfig.origin}/base/${baseId}/${tableId}`;
+ const viewRecordUrlPrefix = `${this.mailConfig.origin}/base/${baseId}/table/${tableId}`;
const { brandName } = await this.settingOpenApiService.getServerBrand();
if (refLength <= 1) {
subject = this.i18n.t('common.email.templates.collaboratorCellTag.subject', {
diff --git a/apps/nestjs-backend/src/features/notification/notification.service.ts b/apps/nestjs-backend/src/features/notification/notification.service.ts
index ae3bc9a772..b5d582266d 100644
--- a/apps/nestjs-backend/src/features/notification/notification.service.ts
+++ b/apps/nestjs-backend/src/features/notification/notification.service.ts
@@ -541,18 +541,18 @@ export class NotificationService {
switch (notifyType) {
case NotificationTypeEnum.System: {
const { baseId, tableId } = urlMeta || {};
- return `/base/${baseId}/${tableId}`;
+ return `/base/${baseId}/table/${tableId}`;
}
case NotificationTypeEnum.Comment: {
const { baseId, tableId, recordId, commentId } = urlMeta || {};
- return `/base/${baseId}/${tableId}${`?recordId=${recordId}&commentId=${commentId}`}`;
+ return `/base/${baseId}/table/${tableId}${`?recordId=${recordId}&commentId=${commentId}`}`;
}
case NotificationTypeEnum.CollaboratorCellTag:
case NotificationTypeEnum.CollaboratorMultiRowTag: {
const { baseId, tableId, recordId } = urlMeta || {};
- return `/base/${baseId}/${tableId}${recordId ? `?recordId=${recordId}` : ''}`;
+ return `/base/${baseId}/table/${tableId}${recordId ? `?recordId=${recordId}` : ''}`;
}
case NotificationTypeEnum.ExportBase: {
const { downloadUrl } = urlMeta || {};
diff --git a/apps/nestjs-backend/src/features/pin/pin.service.ts b/apps/nestjs-backend/src/features/pin/pin.service.ts
index f0ba189d37..1d63a2d13b 100644
--- a/apps/nestjs-backend/src/features/pin/pin.service.ts
+++ b/apps/nestjs-backend/src/features/pin/pin.service.ts
@@ -5,14 +5,19 @@ import { HttpErrorCode, nullsToUndefined, type ViewType } from '@teable/core';
import { Prisma, PrismaService } from '@teable/db-main-prisma';
import type { IGetPinListVo, AddPinRo, DeletePinRo, UpdatePinOrderRo } from '@teable/openapi';
import { PinType } from '@teable/openapi';
+import { Knex } from 'knex';
import { keyBy } from 'lodash';
+import { InjectModel } from 'nest-knexjs';
import { ClsService } from 'nestjs-cls';
import { CustomHttpException } from '../../custom.exception';
import type {
+ AppDeleteEvent,
BaseDeleteEvent,
+ DashboardDeleteEvent,
SpaceDeleteEvent,
TableDeleteEvent,
ViewDeleteEvent,
+ WorkflowDeleteEvent,
} from '../../event-emitter/events';
import { Events } from '../../event-emitter/events';
import type { IClsStore } from '../../types/cls';
@@ -23,7 +28,8 @@ import { getPublicFullStorageUrl } from '../attachments/plugins/utils';
export class PinService {
constructor(
private readonly prismaService: PrismaService,
- private readonly cls: ClsService
+ private readonly cls: ClsService,
+ @InjectModel('CUSTOM_KNEX') private readonly knex: Knex
) {}
private async getMaxOrder(where: Prisma.PinResourceWhereInput) {
@@ -93,124 +99,47 @@ export class PinService {
order: 'asc',
},
});
- const baseIds: string[] = [];
- const spaceIds: string[] = [];
- const tableIds: string[] = [];
- const viewIds: string[] = [];
- list.forEach((item) => {
- switch (item.type) {
- case PinType.Base:
- baseIds.push(item.resourceId);
- break;
- case PinType.Space:
- spaceIds.push(item.resourceId);
- break;
- case PinType.Table:
- tableIds.push(item.resourceId);
- break;
- case PinType.View:
- viewIds.push(item.resourceId);
- break;
- }
- });
- const baseList = baseIds.length
- ? await this.prismaService.base.findMany({
- where: {
- id: { in: baseIds },
- deletedTime: null,
- },
- select: {
- id: true,
- name: true,
- icon: true,
- },
- })
- : [];
- const spaceList = spaceIds.length
- ? await this.prismaService.space.findMany({
- where: { id: { in: spaceIds }, deletedTime: null },
- select: {
- id: true,
- name: true,
- },
- })
- : [];
- const tableList = tableIds.length
- ? await this.prismaService.tableMeta.findMany({
- where: { id: { in: tableIds }, deletedTime: null },
- select: {
- id: true,
- name: true,
- baseId: true,
- icon: true,
- },
- })
- : [];
- const viewList = viewIds.length
- ? await this.prismaService.$queryRaw<
- {
- id: string;
- name: string;
- base_id: string;
- table_id: string;
- type: ViewType;
- options: string;
- }[]
- >(Prisma.sql`
- SELECT view.id, view.name, table_meta.base_id as base_id, table_meta.id as table_id, view.type, view.options FROM view left join table_meta on view.table_id = table_meta.id WHERE view.id IN (${Prisma.join(viewIds)}) and view.deleted_time is null and table_meta.deleted_time is null
- `)
- : [];
- const spaceMap = keyBy(spaceList, 'id');
- const baseMap = keyBy(baseList, 'id');
- const tableMap = keyBy(tableList, 'id');
- const viewMap = keyBy(viewList, 'id');
- const getResource = (type: PinType, resourceId: string) => {
- switch (type) {
- case PinType.Base:
- return baseMap[resourceId]
- ? {
- name: baseMap[resourceId].name,
- icon: baseMap[resourceId].icon,
- }
- : undefined;
- case PinType.Space:
- return spaceMap[resourceId]
- ? {
- name: spaceMap[resourceId].name,
- }
- : undefined;
- case PinType.Table:
- return tableMap[resourceId]
- ? {
- name: tableMap[resourceId].name,
- parentBaseId: tableMap[resourceId].baseId,
- icon: tableMap[resourceId].icon,
- }
- : undefined;
- case PinType.View: {
- const view = viewMap[resourceId];
- if (!view) {
- return undefined;
- }
- const pluginLogo = view.options ? JSON.parse(view.options)?.pluginLogo : undefined;
- return {
- name: view.name,
- parentBaseId: view.base_id,
- viewMeta: {
- tableId: view.table_id,
- type: view.type,
- pluginLogo: pluginLogo ? getPublicFullStorageUrl(pluginLogo) : undefined,
- },
- };
+
+ // Group resource IDs by type
+ const idsByType = list.reduce(
+ (acc, item) => {
+ const type = item.type as PinType;
+ if (!acc[type]) {
+ acc[type] = [];
}
- default:
- return undefined;
- }
+ acc[type].push(item.resourceId);
+ return acc;
+ },
+ {} as Record
+ );
+
+ // Fetch all resources in parallel
+ const [baseList, spaceList, tableList, viewList, dashboardList, workflowList, appList] =
+ await Promise.all([
+ this.fetchBases(idsByType[PinType.Base]),
+ this.fetchSpaces(idsByType[PinType.Space]),
+ this.fetchTables(idsByType[PinType.Table]),
+ this.fetchViews(idsByType[PinType.View]),
+ this.fetchDashboards(idsByType[PinType.Dashboard]),
+ this.fetchWorkflows(idsByType[PinType.Workflow]),
+ this.fetchApps(idsByType[PinType.App]),
+ ]);
+
+ // Create lookup maps
+ const resourceMaps = {
+ [PinType.Base]: keyBy(baseList, 'id'),
+ [PinType.Space]: keyBy(spaceList, 'id'),
+ [PinType.Table]: keyBy(tableList, 'id'),
+ [PinType.View]: keyBy(viewList, 'id'),
+ [PinType.Dashboard]: keyBy(dashboardList, 'id'),
+ [PinType.Workflow]: keyBy(workflowList, 'id'),
+ [PinType.App]: keyBy(appList, 'id'),
};
+
return list
.map((item) => {
const { resourceId, type, order } = item;
- const resource = getResource(type as PinType, resourceId);
+ const resource = this.transformResource(type as PinType, resourceId, resourceMaps);
if (!resource) {
return undefined;
}
@@ -224,6 +153,111 @@ export class PinService {
.filter(Boolean) as IGetPinListVo;
}
+ private async fetchBases(ids?: string[]) {
+ if (!ids?.length) return [];
+ return this.prismaService.base.findMany({
+ where: { id: { in: ids }, deletedTime: null },
+ select: { id: true, name: true, icon: true },
+ });
+ }
+
+ private async fetchSpaces(ids?: string[]) {
+ if (!ids?.length) return [];
+ return this.prismaService.space.findMany({
+ where: { id: { in: ids }, deletedTime: null },
+ select: { id: true, name: true },
+ });
+ }
+
+ private async fetchTables(ids?: string[]) {
+ if (!ids?.length) return [];
+ return this.prismaService.tableMeta.findMany({
+ where: { id: { in: ids }, deletedTime: null },
+ select: { id: true, name: true, baseId: true, icon: true },
+ });
+ }
+
+ private async fetchViews(ids?: string[]) {
+ if (!ids?.length) return [];
+ return this.prismaService.$queryRaw<
+ {
+ id: string;
+ name: string;
+ baseId: string;
+ tableId: string;
+ type: ViewType;
+ options: string;
+ }[]
+ >(Prisma.sql`
+ SELECT view.id, view.name, table_meta.base_id as "baseId", table_meta.id as "tableId", view.type, view.options
+ FROM view
+ LEFT JOIN table_meta ON view.table_id = table_meta.id
+ WHERE view.id IN (${Prisma.join(ids)})
+ AND view.deleted_time IS NULL
+ AND table_meta.deleted_time IS NULL
+ `);
+ }
+
+ private async fetchDashboards(ids?: string[]) {
+ if (!ids?.length) return [];
+ return this.prismaService.dashboard.findMany({
+ where: { id: { in: ids } },
+ select: { id: true, name: true, baseId: true },
+ });
+ }
+
+ private async fetchWorkflows(ids?: string[]) {
+ if (!ids?.length) return [];
+ const sql = this.knex('workflow')
+ .select('id', 'name', this.knex.raw('base_id as "baseId"'))
+ .whereIn('id', ids)
+ .whereNull('deleted_time')
+ .toQuery();
+ return this.prismaService.$queryRawUnsafe<{ id: string; name: string; baseId: string }[]>(sql);
+ }
+
+ private async fetchApps(ids?: string[]) {
+ if (!ids?.length) return [];
+ const sql = this.knex('app')
+ .select('id', 'name', this.knex.raw('base_id as "baseId"'))
+ .whereIn('id', ids)
+ .whereNull('deleted_time')
+ .toQuery();
+ return this.prismaService.$queryRawUnsafe<{ id: string; name: string; baseId: string }[]>(sql);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ private transformResource(type: PinType, resourceId: string, resourceMaps: Record) {
+ const resource = resourceMaps[type]?.[resourceId];
+ if (!resource) return undefined;
+
+ switch (type) {
+ case PinType.Base:
+ return { name: resource.name, icon: resource.icon };
+ case PinType.Space:
+ case PinType.Dashboard:
+ case PinType.Workflow:
+ case PinType.App:
+ return { name: resource.name, parentBaseId: resource.baseId };
+ case PinType.Table:
+ return { name: resource.name, parentBaseId: resource.baseId, icon: resource.icon };
+ case PinType.View: {
+ const pluginLogo = resource.options ? JSON.parse(resource.options)?.pluginLogo : undefined;
+ return {
+ name: resource.name,
+ parentBaseId: resource.baseId,
+ viewMeta: {
+ tableId: resource.tableId,
+ type: resource.type,
+ pluginLogo: pluginLogo ? getPublicFullStorageUrl(pluginLogo) : undefined,
+ },
+ };
+ }
+ default:
+ return undefined;
+ }
+ }
+
async updateOrder(data: UpdatePinOrderRo) {
const { id, type, position, anchorId, anchorType } = data;
@@ -322,8 +356,18 @@ export class PinService {
@OnEvent(Events.TABLE_DELETE, { async: true })
@OnEvent(Events.BASE_DELETE, { async: true })
@OnEvent(Events.SPACE_DELETE, { async: true })
+ @OnEvent(Events.DASHBOARD_DELETE, { async: true })
+ @OnEvent(Events.WORKFLOW_DELETE, { async: true })
+ @OnEvent(Events.APP_DELETE, { async: true })
protected async resourceDeleteListener(
- listenerEvent: ViewDeleteEvent | TableDeleteEvent | BaseDeleteEvent | SpaceDeleteEvent
+ listenerEvent:
+ | ViewDeleteEvent
+ | TableDeleteEvent
+ | BaseDeleteEvent
+ | SpaceDeleteEvent
+ | DashboardDeleteEvent
+ | WorkflowDeleteEvent
+ | AppDeleteEvent
) {
switch (listenerEvent.name) {
case Events.TABLE_VIEW_DELETE:
@@ -350,6 +394,24 @@ export class PinService {
type: PinType.Space,
});
break;
+ case Events.DASHBOARD_DELETE:
+ await this.deletePinWithoutException({
+ id: listenerEvent.payload.dashboardId,
+ type: PinType.Dashboard,
+ });
+ break;
+ case Events.WORKFLOW_DELETE:
+ await this.deletePinWithoutException({
+ id: listenerEvent.payload.workflowId,
+ type: PinType.Workflow,
+ });
+ break;
+ case Events.APP_DELETE:
+ await this.deletePinWithoutException({
+ id: listenerEvent.payload.appId,
+ type: PinType.App,
+ });
+ break;
}
}
}
diff --git a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.controller.ts b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.controller.ts
index 97f3cecf4e..d7896d8b63 100644
--- a/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.controller.ts
+++ b/apps/nestjs-backend/src/features/setting/open-api/setting-open-api.controller.ts
@@ -64,6 +64,7 @@ export class SettingOpenApiController {
SettingKey.DISALLOW_SIGN_UP,
SettingKey.DISALLOW_SPACE_CREATION,
SettingKey.DISALLOW_SPACE_INVITATION,
+ SettingKey.DISALLOW_DASHBOARD,
SettingKey.ENABLE_EMAIL_VERIFICATION,
SettingKey.ENABLE_WAITLIST,
SettingKey.AI_CONFIG,
diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts
index 3c1b9dd605..755b597f27 100644
--- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts
+++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.service.ts
@@ -270,11 +270,15 @@ export class TableOpenApiService {
return tablesMeta.map((tableMeta, i) => {
const defaultViewId = tableDefaultViewIds[i];
if (!defaultViewId) {
- throw new CustomHttpException('defaultViewId is not found', HttpErrorCode.NOT_FOUND, {
- localization: {
- i18nKey: 'httpErrors.view.defaultViewNotFound',
- },
- });
+ throw new CustomHttpException(
+ `defaultViewId is not found in table ${tableMeta.id}`,
+ HttpErrorCode.NOT_FOUND,
+ {
+ localization: {
+ i18nKey: 'httpErrors.view.defaultViewNotFound',
+ },
+ }
+ );
}
return {
...tableMeta,
diff --git a/apps/nestjs-backend/src/features/table/open-api/table.pipe.helper.ts b/apps/nestjs-backend/src/features/table/open-api/table.pipe.helper.ts
new file mode 100644
index 0000000000..66925d681d
--- /dev/null
+++ b/apps/nestjs-backend/src/features/table/open-api/table.pipe.helper.ts
@@ -0,0 +1,29 @@
+import type { IFieldVo } from '@teable/core';
+import { HttpErrorCode, PRIMARY_SUPPORTED_TYPES } from '@teable/core';
+import type { ICreateTableRo, ICreateTableWithDefault } from '@teable/openapi';
+import { CustomHttpException } from '../../../custom.exception';
+import { DEFAULT_FIELDS, DEFAULT_VIEWS, DEFAULT_RECORD_DATA } from '../constant';
+
+export const prepareCreateTableRo = (tableRo: ICreateTableRo): ICreateTableWithDefault => {
+ const fieldRos = tableRo.fields && tableRo.fields.length ? tableRo.fields : DEFAULT_FIELDS;
+ // make sure first field to be the primary field;
+ (fieldRos[0] as IFieldVo).isPrimary = true;
+ if (!PRIMARY_SUPPORTED_TYPES.has(fieldRos[0].type)) {
+ throw new CustomHttpException(
+ `Field type ${fieldRos[0].type} is not supported as primary field`,
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.field.primaryFieldNotSupported',
+ },
+ }
+ );
+ }
+
+ return {
+ ...tableRo,
+ fields: fieldRos,
+ views: tableRo.views && tableRo.views.length ? tableRo.views : DEFAULT_VIEWS,
+ records: tableRo.records ? tableRo.records : DEFAULT_RECORD_DATA,
+ };
+};
diff --git a/apps/nestjs-backend/src/features/table/open-api/table.pipe.ts b/apps/nestjs-backend/src/features/table/open-api/table.pipe.ts
index e835614795..970519f4bf 100644
--- a/apps/nestjs-backend/src/features/table/open-api/table.pipe.ts
+++ b/apps/nestjs-backend/src/features/table/open-api/table.pipe.ts
@@ -1,37 +1,11 @@
import type { ArgumentMetadata, PipeTransform } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
-import { HttpErrorCode, PRIMARY_SUPPORTED_TYPES, type IFieldVo } from '@teable/core';
import type { ICreateTableRo } from '@teable/openapi';
-import { CustomHttpException } from '../../../custom.exception';
-import { DEFAULT_FIELDS, DEFAULT_RECORD_DATA, DEFAULT_VIEWS } from '../constant';
+import { prepareCreateTableRo } from './table.pipe.helper';
@Injectable()
export class TablePipe implements PipeTransform {
async transform(value: ICreateTableRo, _metadata: ArgumentMetadata) {
- return this.prepareDefaultRo(value);
- }
-
- async prepareDefaultRo(tableRo: ICreateTableRo): Promise {
- const fieldRos = tableRo.fields && tableRo.fields.length ? tableRo.fields : DEFAULT_FIELDS;
- // make sure first field to be the primary field;
- (fieldRos[0] as IFieldVo).isPrimary = true;
- if (!PRIMARY_SUPPORTED_TYPES.has(fieldRos[0].type)) {
- throw new CustomHttpException(
- `Field type ${fieldRos[0].type} is not supported as primary field`,
- HttpErrorCode.VALIDATION_ERROR,
- {
- localization: {
- i18nKey: 'httpErrors.field.primaryFieldNotSupported',
- },
- }
- );
- }
-
- return {
- ...tableRo,
- fields: fieldRos,
- views: tableRo.views && tableRo.views.length ? tableRo.views : DEFAULT_VIEWS,
- records: tableRo.records ? tableRo.records : DEFAULT_RECORD_DATA,
- };
+ return prepareCreateTableRo(value);
}
}
diff --git a/apps/nestjs-backend/src/features/table/table-duplicate.service.ts b/apps/nestjs-backend/src/features/table/table-duplicate.service.ts
index e86718e6f7..c99ddf49e0 100644
--- a/apps/nestjs-backend/src/features/table/table-duplicate.service.ts
+++ b/apps/nestjs-backend/src/features/table/table-duplicate.service.ts
@@ -116,6 +116,9 @@ export class TableDuplicateService {
tableId: newTableVo.id,
deletedTime: null,
},
+ orderBy: {
+ order: 'asc',
+ },
});
const fieldPlain = await this.prismaService.txClient().field.findMany({
@@ -134,6 +137,7 @@ export class TableDuplicateService {
fields: fieldPlain.map((f) => omit(rawField2FieldObj(f), ['meta'])),
viewMap: sourceToTargetViewMap,
fieldMap: sourceToTargetFieldMap,
+ defaultViewId: viewPlain[0]?.id,
} as IDuplicateTableVo;
},
{
diff --git a/apps/nestjs-backend/src/features/user/last-visit/last-visit.controller.ts b/apps/nestjs-backend/src/features/user/last-visit/last-visit.controller.ts
index 3193188618..9966e5a9e4 100644
--- a/apps/nestjs-backend/src/features/user/last-visit/last-visit.controller.ts
+++ b/apps/nestjs-backend/src/features/user/last-visit/last-visit.controller.ts
@@ -1,12 +1,15 @@
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import type {
+ IUserLastVisitBaseNodeVo,
IUserLastVisitListBaseVo,
IUserLastVisitMapVo,
IUserLastVisitVo,
} from '@teable/openapi';
import {
IGetUserLastVisitRo,
+ IGetUserLastVisitBaseNodeRo,
IUpdateUserLastVisitRo,
+ getUserLastVisitBaseNodeRoSchema,
getUserLastVisitRoSchema,
updateUserLastVisitRoSchema,
} from '@teable/openapi';
@@ -51,4 +54,13 @@ export class LastVisitController {
async getUserLastVisitListBase(): Promise {
return this.lastVisitService.baseVisit();
}
+
+ @Get('/base-node')
+ async getUserLastVisitBaseNode(
+ @Query(new ZodValidationPipe(getUserLastVisitBaseNodeRoSchema))
+ params: IGetUserLastVisitBaseNodeRo
+ ): Promise {
+ const userId = this.cls.get('user.id');
+ return this.lastVisitService.getUserLastVisitBaseNode(userId, params);
+ }
}
diff --git a/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts b/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts
index fcf6de5858..963498d80d 100644
--- a/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts
+++ b/apps/nestjs-backend/src/features/user/last-visit/last-visit.service.ts
@@ -6,10 +6,12 @@ import { HttpErrorCode, type IRole } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import type {
IGetUserLastVisitRo,
+ IGetUserLastVisitBaseNodeRo,
IUpdateUserLastVisitRo,
IUserLastVisitListBaseVo,
IUserLastVisitMapVo,
IUserLastVisitVo,
+ IUserLastVisitBaseNodeVo,
} from '@teable/openapi';
import { LastVisitResourceType } from '@teable/openapi';
import { Knex } from 'knex';
@@ -18,7 +20,15 @@ import { InjectModel } from 'nest-knexjs';
import { ClsService } from 'nestjs-cls';
import { CustomHttpException } from '../../../custom.exception';
import { EventEmitterService } from '../../../event-emitter/event-emitter.service';
-import type { BaseDeleteEvent, SpaceDeleteEvent } from '../../../event-emitter/events';
+import type {
+ BaseDeleteEvent,
+ SpaceDeleteEvent,
+ DashboardDeleteEvent,
+ WorkflowDeleteEvent,
+ AppDeleteEvent,
+ TableDeleteEvent,
+ ViewDeleteEvent,
+} from '../../../event-emitter/events';
import { Events } from '../../../event-emitter/events';
import { LastVisitUpdateEvent } from '../../../event-emitter/events/last-visit/last-visit.event';
import type { IClsStore } from '../../../types/cls';
@@ -32,6 +42,43 @@ export class LastVisitService {
private readonly eventEmitterService: EventEmitterService
) {}
+ async getUserLastVisitBaseNode(
+ userId: string,
+ params: IGetUserLastVisitBaseNodeRo
+ ): Promise {
+ const lastVisit = await this.prismaService.userLastVisit.findFirst({
+ where: {
+ userId,
+ parentResourceId: params.parentResourceId,
+ resourceType: {
+ in: [
+ LastVisitResourceType.Table,
+ LastVisitResourceType.Dashboard,
+ LastVisitResourceType.Automation,
+ LastVisitResourceType.App,
+ ],
+ },
+ },
+ orderBy: {
+ lastVisitTime: 'desc',
+ },
+ take: 1,
+ select: {
+ resourceId: true,
+ resourceType: true,
+ },
+ });
+
+ if (!lastVisit) {
+ return;
+ }
+
+ return {
+ resourceId: lastVisit.resourceId,
+ resourceType: lastVisit.resourceType as LastVisitResourceType,
+ };
+ }
+
async tableVisit(userId: string, baseId: string): Promise {
const knex = this.knex;
@@ -295,6 +342,55 @@ export class LastVisitService {
}
}
+ async appVisit(userId: string, parentResourceId: string) {
+ const query = this.knex
+ .select({
+ resourceId: 'ulv.resource_id',
+ })
+ .from('user_last_visit as ulv')
+ .leftJoin('app as a', function () {
+ this.on('a.id', '=', 'ulv.resource_id').andOnNull('a.deleted_time');
+ })
+ .where('ulv.user_id', userId)
+ .where('ulv.resource_type', LastVisitResourceType.App)
+ .where('ulv.parent_resource_id', parentResourceId)
+ .whereNotNull('a.id')
+ .limit(1)
+ .toQuery();
+
+ const results = await this.prismaService.$queryRawUnsafe(query);
+ const lastVisit = results[0];
+
+ if (lastVisit) {
+ return {
+ resourceId: lastVisit.resourceId,
+ resourceType: LastVisitResourceType.App,
+ };
+ }
+
+ const appQuery = this.knex('app')
+ .select({
+ id: 'id',
+ })
+ .where('base_id', parentResourceId)
+ .whereNull('deleted_time')
+ .orderBy('last_modified_time', 'desc')
+ .limit(1)
+ .toQuery();
+
+ const appResults = await this.prismaService.$queryRawUnsafe<{ id: string }[]>(appQuery);
+ const app = appResults[0];
+
+ if (app) {
+ return {
+ resourceId: app.id,
+ resourceType: LastVisitResourceType.App,
+ };
+ }
+
+ return undefined;
+ }
+
async baseVisit(): Promise {
const userId = this.cls.get('user.id');
const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
@@ -375,6 +471,8 @@ export class LastVisitService {
return this.dashboardVisit(userId, params.parentResourceId);
case LastVisitResourceType.Automation:
return this.automationVisit(userId, params.parentResourceId);
+ case LastVisitResourceType.App:
+ return this.appVisit(userId, params.parentResourceId);
default:
throw new CustomHttpException('Invalid resource type', HttpErrorCode.VALIDATION_ERROR, {
localization: {
@@ -559,7 +657,21 @@ export class LastVisitService {
@OnEvent(Events.BASE_DELETE, { async: true })
@OnEvent(Events.SPACE_DELETE, { async: true })
- protected async resourceDeleteListener(listenerEvent: BaseDeleteEvent | SpaceDeleteEvent) {
+ @OnEvent(Events.TABLE_DELETE, { async: true })
+ @OnEvent(Events.TABLE_VIEW_DELETE, { async: true })
+ @OnEvent(Events.DASHBOARD_DELETE, { async: true })
+ @OnEvent(Events.WORKFLOW_DELETE, { async: true })
+ @OnEvent(Events.APP_DELETE, { async: true })
+ protected async resourceDeleteListener(
+ listenerEvent:
+ | BaseDeleteEvent
+ | SpaceDeleteEvent
+ | TableDeleteEvent
+ | ViewDeleteEvent
+ | DashboardDeleteEvent
+ | WorkflowDeleteEvent
+ | AppDeleteEvent
+ ) {
switch (listenerEvent.name) {
case Events.BASE_DELETE:
await this.prismaService.userLastVisit.deleteMany({
@@ -585,6 +697,54 @@ export class LastVisitService {
},
});
break;
+ case Events.TABLE_DELETE:
+ await this.prismaService.userLastVisit.deleteMany({
+ where: {
+ OR: [
+ {
+ resourceId: listenerEvent.payload.tableId,
+ resourceType: LastVisitResourceType.Table,
+ },
+ {
+ parentResourceId: listenerEvent.payload.tableId,
+ resourceType: LastVisitResourceType.View,
+ },
+ ],
+ },
+ });
+ break;
+ case Events.TABLE_VIEW_DELETE:
+ await this.prismaService.userLastVisit.deleteMany({
+ where: {
+ resourceId: listenerEvent.payload.viewId,
+ resourceType: LastVisitResourceType.View,
+ },
+ });
+ break;
+ case Events.DASHBOARD_DELETE:
+ await this.prismaService.userLastVisit.deleteMany({
+ where: {
+ resourceId: listenerEvent.payload.dashboardId,
+ resourceType: LastVisitResourceType.Dashboard,
+ },
+ });
+ break;
+ case Events.WORKFLOW_DELETE:
+ await this.prismaService.userLastVisit.deleteMany({
+ where: {
+ resourceId: listenerEvent.payload.workflowId,
+ resourceType: LastVisitResourceType.Automation,
+ },
+ });
+ break;
+ case Events.APP_DELETE:
+ await this.prismaService.userLastVisit.deleteMany({
+ where: {
+ resourceId: listenerEvent.payload.appId,
+ resourceType: LastVisitResourceType.App,
+ },
+ });
+ break;
}
this.eventEmitterService.emitAsync(Events.LAST_VISIT_CLEAR, {});
diff --git a/apps/nestjs-backend/src/performance-cache/generate-keys.ts b/apps/nestjs-backend/src/performance-cache/generate-keys.ts
index 92868ff28e..1782246293 100644
--- a/apps/nestjs-backend/src/performance-cache/generate-keys.ts
+++ b/apps/nestjs-backend/src/performance-cache/generate-keys.ts
@@ -41,3 +41,7 @@ export function generateSettingCacheKey() {
export function generateIntegrationCacheKey(spaceId: string) {
return `integration:${spaceId}` as const;
}
+
+export function generateBaseNodeListCacheKey(baseId: string) {
+ return `base-node-list:${baseId}` as const;
+}
diff --git a/apps/nestjs-backend/src/performance-cache/types.ts b/apps/nestjs-backend/src/performance-cache/types.ts
index d9e5120f84..17905cdece 100644
--- a/apps/nestjs-backend/src/performance-cache/types.ts
+++ b/apps/nestjs-backend/src/performance-cache/types.ts
@@ -29,6 +29,9 @@ export interface IPerformanceCacheStore {
// instance setting cache, format: instance:setting
'instance:setting': unknown;
+
+ // base node list cache, format: base-node-list:base_id
+ [key: `base-node-list:${string}`]: unknown;
}
/**
diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts
index de1daec718..1f5e474a5a 100644
--- a/apps/nestjs-backend/src/types/i18n.generated.ts
+++ b/apps/nestjs-backend/src/types/i18n.generated.ts
@@ -456,6 +456,7 @@ export type I18nTranslations = {
"dashboard": string;
"automation": string;
"authorityMatrix": string;
+ "design": string;
"adminPanel": string;
"license": string;
"instanceId": string;
@@ -464,7 +465,6 @@ export type I18nTranslations = {
"global": string;
"organizationPanel": string;
"unknownError": string;
- "design": string;
"pluginPanel": string;
"pluginContextMenu": string;
"plugin": string;
@@ -473,7 +473,7 @@ export type I18nTranslations = {
"aiChat": string;
"app": string;
"webSearch": string;
- "float": string;
+ "folder": string;
};
"level": {
"free": string;
@@ -1040,6 +1040,10 @@ export type I18nTranslations = {
};
"findDashboard": string;
"expand": string;
+ "deprecation": {
+ "title": string;
+ "description": string;
+ };
"pluginUrlEmpty": string;
"install": string;
"publisher": string;
@@ -2203,6 +2207,24 @@ export type I18nTranslations = {
"baseAndSpaceMismatch": string;
"templateNotFound": string;
};
+ "baseNode": {
+ "invalidResourceType": string;
+ "notFound": string;
+ "parentMustBeFolder": string;
+ "cannotDuplicateFolder": string;
+ "cannotDeleteEmptyFolder": string;
+ "onlyOneOfParentIdOrAnchorIdRequired": string;
+ "cannotMoveToItself": string;
+ "cannotMoveToCircularReference": string;
+ "anchorIdOrParentIdRequired": string;
+ "parentNotFound": string;
+ "parentIsNotFolder": string;
+ "circularReference": string;
+ "folderDepthLimitExceeded": string;
+ "folderNotFound": string;
+ "anchorNotFound": string;
+ "nameAlreadyExists": string;
+ };
"dashboard": {
"notFound": string;
};
diff --git a/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts b/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts
index 4f8dc5fc1b..56c5e30929 100644
--- a/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts
+++ b/apps/nestjs-backend/test/base-duplicate.e2e-spec.ts
@@ -11,8 +11,10 @@ import {
} from '@teable/core';
import type { ICreateBaseVo, ICreateSpaceVo } from '@teable/openapi';
import {
+ BaseNodeResourceType,
CREATE_SPACE,
createBase,
+ createBaseNode,
createDashboard,
createField,
createPluginPanel,
@@ -22,6 +24,7 @@ import {
duplicateBase,
EMAIL_SPACE_INVITATION,
getBaseList,
+ getBaseNodeTree,
getDashboard,
getDashboardInstallPlugin,
getDashboardList,
@@ -36,6 +39,7 @@ import {
installViewPlugin,
listPluginPanels,
LLMProviderType,
+ moveBaseNode,
updateSetting,
urlBuilder,
} from '@teable/openapi';
@@ -48,6 +52,7 @@ import {
getRecords,
initApp,
updateRecord,
+ permanentDeleteBase,
} from './utils/init-app';
describe('OpenAPI Base Duplicate (e2e)', () => {
@@ -55,6 +60,7 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
let base: ICreateBaseVo;
let spaceId: string;
let newUserAxios: AxiosInstance;
+ let duplicateBaseId: string | undefined;
beforeAll(async () => {
const appCtx = await initApp();
app = appCtx.app;
@@ -83,7 +89,11 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
});
afterEach(async () => {
- await deleteBase(base.id);
+ await permanentDeleteBase(base.id);
+ if (duplicateBaseId) {
+ await permanentDeleteBase(duplicateBaseId);
+ duplicateBaseId = undefined;
+ }
});
if (globalThis.testConfig.driver !== DriverClient.Pg) {
@@ -428,6 +438,152 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
await deleteBase(dupResult.data.id);
});
+ it('should duplicate the base with node [Folder, Table, Dashboard]', async () => {
+ const nodeBaseId = base.id;
+
+ // Create folders using createBaseNode
+ const folder1Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder 1',
+ }).then((res) => res.data);
+ const folder2Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder 2',
+ }).then((res) => res.data);
+
+ // Create tables using createBaseNode
+ const table1Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Table 1',
+ fields: [{ name: 'Title', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ }).then((res) => res.data);
+ const table2Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Table 2',
+ fields: [{ name: 'Name', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ }).then((res) => res.data);
+
+ // Create dashboards using createBaseNode
+ const dashboard1Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Dashboard 1',
+ }).then((res) => res.data);
+ const dashboard2Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Dashboard 2',
+ }).then((res) => res.data);
+
+ // Move table1 into folder1 and dashboard1 into folder2
+ await moveBaseNode(nodeBaseId, table1Node.id, { parentId: folder1Node.id });
+ await moveBaseNode(nodeBaseId, dashboard1Node.id, { parentId: folder2Node.id });
+
+ // Get updated node tree
+ const updatedSourceNodeTree = await getBaseNodeTree(nodeBaseId).then((res) => res.data);
+ const updatedSourceNodes = updatedSourceNodeTree.nodes;
+
+ // Duplicate the base
+ const dupResult = await duplicateBase({
+ fromBaseId: base.id,
+ spaceId: spaceId,
+ name: 'test base copy',
+ }).then((res) => res.data);
+
+ duplicateBaseId = dupResult.id;
+
+ // Verify duplicated node tree
+ const duplicatedNodeTree = await getBaseNodeTree(duplicateBaseId).then((res) => res.data);
+ const duplicatedNodes = duplicatedNodeTree.nodes;
+
+ // Verify same number of nodes
+ expect(duplicatedNodes.length).toBe(updatedSourceNodes.length);
+
+ // Verify resource types distribution
+ const sourceResourceTypes = updatedSourceNodes
+ .map((n) => n.resourceType)
+ .sort()
+ .join(',');
+ const duplicatedResourceTypes = duplicatedNodes
+ .map((n) => n.resourceType)
+ .sort()
+ .join(',');
+ expect(duplicatedResourceTypes).toBe(sourceResourceTypes);
+
+ // Verify folder count
+ const sourceFolders = updatedSourceNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Folder
+ );
+ const duplicatedFolders = duplicatedNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Folder
+ );
+ expect(duplicatedFolders.length).toBe(sourceFolders.length);
+
+ // Verify table count
+ const sourceTables = updatedSourceNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Table
+ );
+ const duplicatedTables = duplicatedNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Table
+ );
+ expect(duplicatedTables.length).toBe(sourceTables.length);
+
+ // Verify dashboard count
+ const sourceDashboards = updatedSourceNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Dashboard
+ );
+ const duplicatedDashboards = duplicatedNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Dashboard
+ );
+ expect(duplicatedDashboards.length).toBe(sourceDashboards.length);
+
+ // Verify hierarchy: nodes with parents should still have parents
+ const sourceNodesWithParent = updatedSourceNodes.filter((n) => n.parentId !== null);
+ const duplicatedNodesWithParent = duplicatedNodes.filter((n) => n.parentId !== null);
+ expect(duplicatedNodesWithParent.length).toBe(sourceNodesWithParent.length);
+
+ // Verify folder names are preserved
+ const sourceFolderNames = sourceFolders.map((f) => f.resourceMeta?.name).sort();
+ const duplicatedFolderNames = duplicatedFolders.map((f) => f.resourceMeta?.name).sort();
+ expect(duplicatedFolderNames).toEqual(sourceFolderNames);
+
+ // Verify that table inside folder1 exists in imported base
+ const duplicatedFolder1 = duplicatedFolders.find(
+ (f) => f.resourceMeta?.name === folder1Node.resourceMeta?.name
+ );
+ expect(duplicatedFolder1).toBeDefined();
+ const tableInsideFolder = duplicatedNodes.find((n) => {
+ return n.resourceType === BaseNodeResourceType.Table && n.parentId === duplicatedFolder1!.id;
+ });
+ expect(tableInsideFolder).toBeDefined();
+
+ // Verify that dashboard inside folder2 exists in imported base
+ const duplicatedFolder2 = duplicatedFolders.find(
+ (f) => f.resourceMeta?.name === folder2Node.resourceMeta?.name
+ );
+ expect(duplicatedFolder2).toBeDefined();
+ const dashboardInsideFolder = duplicatedNodes.find((n) => {
+ return (
+ n.resourceType === BaseNodeResourceType.Dashboard && n.parentId === duplicatedFolder2!.id
+ );
+ });
+ expect(dashboardInsideFolder).toBeDefined();
+
+ // Verify tables are accessible
+ const duplicatedTableList = await getTableList(duplicateBaseId).then((res) => res.data);
+ expect(duplicatedTableList.length).toBe(2);
+ expect(duplicatedTableList.map((t) => t.name).sort()).toEqual(
+ [table1Node.resourceMeta?.name, table2Node.resourceMeta?.name].sort()
+ );
+
+ // Verify dashboards are accessible
+ const duplicatedDashboardList = await getDashboardList(duplicateBaseId).then((res) => res.data);
+ expect(duplicatedDashboardList.length).toBe(2);
+ expect(duplicatedDashboardList.map((d) => d.name).sort()).toEqual(
+ [dashboard1Node.resourceMeta?.name, dashboard2Node.resourceMeta?.name].sort()
+ );
+ });
+
describe('Duplicate cross space', () => {
let newSpace: ICreateSpaceVo;
beforeEach(async () => {
@@ -453,7 +609,6 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
expect(baseResult.data.length).toBe(1);
expect(tableResult.data.length).toBe(1);
- await deleteBase(dupResult.data.id);
});
});
@@ -482,7 +637,7 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
spaceId: spaceId,
name: 'test base copy',
});
-
+ duplicateBaseId = dupResult.data.id;
const newBaseId = dupResult.data.id;
const dashboardList = (await getDashboardList(newBaseId)).data;
@@ -500,8 +655,6 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
expect(dashboardList.length).toBe(2);
expect(installedPlugins.name).toBe('plugin1');
-
- await deleteBase(dupResult.data.id);
});
it('should duplicate all panel plugins', async () => {
@@ -530,7 +683,7 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
spaceId: spaceId,
name: 'test base copy',
});
-
+ duplicateBaseId = dupResult.data.id;
const panelList = (await listPluginPanels(pluginTable.id)).data;
const panel1Info = (
@@ -548,8 +701,6 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
expect(panel1Info.layout?.length).toBe(2);
expect(panelList.length).toBe(2);
expect(installedPlugins.name).toBe('plugin1');
-
- await deleteBase(dupResult.data.id);
});
it('should duplicate all view plugins', async () => {
@@ -568,7 +719,7 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
spaceId: spaceId,
name: 'test base copy',
});
-
+ duplicateBaseId = dupResult.data.id;
const views = (await getViewList(tableId)).data;
const pluginViews = views.filter(({ type }) => type === ViewType.Plugin);
@@ -577,8 +728,6 @@ describe('OpenAPI Base Duplicate (e2e)', () => {
expect(pluginViews.find(({ name }) => name === sheetView1.name)).toBeDefined();
expect(pluginViews.find(({ name }) => name === sheetView2.name)).toBeDefined();
-
- await deleteBase(dupResult.data.id);
});
});
});
diff --git a/apps/nestjs-backend/test/base-node-folder.e2e-spec.ts b/apps/nestjs-backend/test/base-node-folder.e2e-spec.ts
new file mode 100644
index 0000000000..c40fb6d85e
--- /dev/null
+++ b/apps/nestjs-backend/test/base-node-folder.e2e-spec.ts
@@ -0,0 +1,260 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import type { INestApplication } from '@nestjs/common';
+import { getRandomString } from '@teable/core';
+import { PrismaService } from '@teable/db-main-prisma';
+import {
+ createBaseNodeFolder,
+ updateBaseNodeFolder,
+ deleteBaseNodeFolder,
+ createBaseNode,
+ BaseNodeResourceType,
+ deleteBaseNode,
+} from '@teable/openapi';
+import { getError } from './utils/get-error';
+import { initApp } from './utils/init-app';
+
+describe('BaseNodeFolderController (e2e) /api/base/:baseId/node/folder', () => {
+ let app: INestApplication;
+ const baseId = globalThis.testConfig.baseId;
+ const folderNameToDelete = 'Folder To Delete';
+ const whitespaceOnlyName = ' ';
+ const originalFolderName = 'Original Folder';
+ let prisma: PrismaService;
+
+ beforeAll(async () => {
+ const appCtx = await initApp();
+ app = appCtx.app;
+ prisma = app.get(PrismaService);
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ describe('POST /api/base/:baseId/node/folder - Create folder', () => {
+ it('should create a folder successfully', async () => {
+ const ro = { name: 'Test Folder' };
+ const response = await createBaseNodeFolder(baseId, ro);
+
+ expect(response.data).toBeDefined();
+ expect(response.data.name).toContain('Test Folder');
+ expect(response.data.id).toBeDefined();
+
+ // Cleanup
+ await deleteBaseNodeFolder(baseId, response.data.id);
+ });
+
+ it('should create multiple folders with same name (auto unique)', async () => {
+ const ro = { name: 'Duplicate Folder' };
+ const response1 = await createBaseNodeFolder(baseId, ro);
+ const response2 = await createBaseNodeFolder(baseId, ro);
+
+ expect(response1.data.name).toContain('Duplicate Folder');
+ expect(response2.data.name).toContain('Duplicate Folder');
+ expect(response1.data.name).not.toBe(response2.data.name);
+ expect(response1.data.id).not.toBe(response2.data.id);
+
+ // Cleanup
+ await deleteBaseNodeFolder(baseId, response1.data.id);
+ await deleteBaseNodeFolder(baseId, response2.data.id);
+ });
+
+ it('should trim folder name', async () => {
+ const ro = { name: ' Trimmed Folder ' };
+ const response = await createBaseNodeFolder(baseId, ro);
+
+ expect(response.data.name).toContain('Trimmed Folder');
+
+ // Cleanup
+ await deleteBaseNodeFolder(baseId, response.data.id);
+ });
+
+ it('should fail with empty name', async () => {
+ const ro = { name: '' };
+ const error = await getError(() => createBaseNodeFolder(baseId, ro));
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should fail with whitespace only name', async () => {
+ const ro = { name: whitespaceOnlyName };
+ const error = await getError(() => createBaseNodeFolder(baseId, ro));
+
+ expect(error?.status).toBe(400);
+ });
+ });
+
+ describe('PATCH /api/base/:baseId/node/folder/:folderId - Update folder', () => {
+ let folderId: string;
+
+ beforeEach(async () => {
+ const response = await createBaseNodeFolder(baseId, { name: originalFolderName });
+ folderId = response.data.id;
+ });
+
+ afterEach(async () => {
+ try {
+ await deleteBaseNodeFolder(baseId, folderId);
+ } catch (e) {
+ // Folder might already be deleted in some tests
+ }
+ });
+
+ it('should rename folder successfully', async () => {
+ const updateRo = { name: 'Renamed Folder' };
+ const response = await updateBaseNodeFolder(baseId, folderId, updateRo);
+
+ expect(response.data).toBeDefined();
+ expect(response.data.name).toBe('Renamed Folder');
+ expect(response.data.id).toBe(folderId);
+ });
+
+ it('should trim folder name when renaming', async () => {
+ const updateRo = { name: ' Trimmed Renamed ' };
+ const response = await updateBaseNodeFolder(baseId, folderId, updateRo);
+
+ expect(response.data.name).toBe('Trimmed Renamed');
+ });
+
+ it('should fail when renaming to existing folder name', async () => {
+ // Create another folder
+ const anotherFolder = await createBaseNodeFolder(baseId, { name: 'Existing Folder' });
+
+ // Try to rename original folder to existing name
+ const updateRo = { name: 'Existing Folder' };
+ const error = await getError(() => updateBaseNodeFolder(baseId, folderId, updateRo));
+
+ expect(error?.status).toBe(400);
+ expect(error?.message).toContain('Folder name already exists');
+
+ // Cleanup
+ await deleteBaseNodeFolder(baseId, anotherFolder.data.id);
+ });
+
+ it('should allow renaming folder to same name', async () => {
+ const updateRo = { name: originalFolderName };
+ const response = await updateBaseNodeFolder(baseId, folderId, updateRo);
+
+ expect(response.data.name).toBe(originalFolderName);
+ });
+
+ it('should fail with empty name', async () => {
+ const updateRo = { name: '' };
+ const error = await getError(() => updateBaseNodeFolder(baseId, folderId, updateRo));
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should fail with whitespace only name', async () => {
+ const updateRo = { name: whitespaceOnlyName };
+ const error = await getError(() => updateBaseNodeFolder(baseId, folderId, updateRo));
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should fail when updating non-existent folder', async () => {
+ const nonExistentId = 'non-existent-folder-id';
+ const updateRo = { name: 'New Name' };
+ const error = await getError(() => updateBaseNodeFolder(baseId, nonExistentId, updateRo));
+
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+ });
+
+ describe('DELETE /api/base/:baseId/node/folder/:folderId - Delete folder', () => {
+ it('should delete empty folder successfully', async () => {
+ // Create a folder
+ const folder = await createBaseNodeFolder(baseId, { name: folderNameToDelete });
+ const folderId = folder.data.id;
+
+ const findFolder = await prisma.baseNodeFolder.findFirst({
+ where: { id: folderId },
+ });
+ expect(findFolder).toBeDefined();
+
+ // Delete the folder
+ await deleteBaseNodeFolder(baseId, folderId);
+
+ const findFolderAfterDelete = await prisma.baseNodeFolder.findFirst({
+ where: { id: folderId },
+ });
+ expect(findFolderAfterDelete).toBeNull();
+
+ // Verify folder is deleted
+ const error = await getError(() => deleteBaseNodeFolder(baseId, folderId));
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('should fail when deleting folder with children', async () => {
+ // Create a parent folder
+ const parentFolder = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Parent Folder',
+ }).then((res) => res.data);
+
+ // Create a child folder inside the parent folder using createBaseNode
+ const childFolder = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ parentId: parentFolder.id,
+ name: 'Child Folder',
+ }).then((res) => res.data);
+
+ // Try to delete the parent folder
+ const error = await getError(() => deleteBaseNode(baseId, parentFolder.id));
+
+ expect(error?.status).toBe(400);
+ expect(error?.message).toContain('Cannot delete folder because it is not empty');
+
+ // Cleanup - need to delete the folder manually after removing children
+ await deleteBaseNode(baseId, childFolder.id);
+ await deleteBaseNode(baseId, parentFolder.id);
+
+ const findFolderAfterDelete = await prisma.baseNodeFolder.findFirst({
+ where: { id: { in: [parentFolder.id, childFolder.id] } },
+ });
+ expect(findFolderAfterDelete).toBeNull();
+ });
+
+ it('should fail when deleting non-existent folder', async () => {
+ const nonExistentId = 'non-existent-folder-id';
+ const error = await getError(() => deleteBaseNodeFolder(baseId, nonExistentId));
+
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('should handle deletion of already deleted folder', async () => {
+ // Create and delete a folder
+ const folder = await createBaseNodeFolder(baseId, { name: 'Temp Folder' });
+ const folderId = folder.data.id;
+ await deleteBaseNodeFolder(baseId, folderId);
+
+ // Try to delete again
+ const error = await getError(() => deleteBaseNodeFolder(baseId, folderId));
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+ });
+
+ describe('Integration tests', () => {
+ it('should create, update and delete folder in sequence', async () => {
+ // Create
+ const createResponse = await createBaseNodeFolder(baseId, { name: 'Integration Folder' });
+ expect(createResponse.data.name).toContain('Integration Folder');
+ const folderId = createResponse.data.id;
+
+ // Update
+ const newName = getRandomString(10);
+ const updateResponse = await updateBaseNodeFolder(baseId, folderId, {
+ name: newName,
+ });
+ expect(updateResponse.data.name).toContain(newName);
+
+ // Delete
+ await deleteBaseNodeFolder(baseId, folderId);
+
+ const findFolderAfterDelete = await prisma.baseNodeFolder.findFirst({
+ where: { id: folderId },
+ });
+ expect(findFolderAfterDelete).toBeNull();
+ });
+ });
+});
diff --git a/apps/nestjs-backend/test/base-node.e2e-spec.ts b/apps/nestjs-backend/test/base-node.e2e-spec.ts
new file mode 100644
index 0000000000..4d7de1c0fa
--- /dev/null
+++ b/apps/nestjs-backend/test/base-node.e2e-spec.ts
@@ -0,0 +1,1598 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import type { INestApplication } from '@nestjs/common';
+import { FieldType, Role, ViewType } from '@teable/core';
+import type { IBaseNodeTableResourceMeta, IBaseNodeVo } from '@teable/openapi';
+import {
+ createBaseNode,
+ getBaseNodeTree,
+ getBaseNode,
+ updateBaseNode,
+ deleteBaseNode,
+ moveBaseNode,
+ duplicateBaseNode,
+ BaseNodeResourceType,
+ createBase,
+ emailBaseInvitation,
+ createSpace as apiCreateSpace,
+ permanentDeleteSpace as apiPermanentDeleteSpace,
+ urlBuilder,
+ GET_BASE_NODE_LIST,
+ GET_BASE_NODE_TREE,
+ GET_BASE_NODE,
+ CREATE_BASE_NODE,
+ UPDATE_BASE_NODE,
+ DELETE_BASE_NODE,
+ MOVE_BASE_NODE,
+ DUPLICATE_BASE_NODE,
+} from '@teable/openapi';
+import type { AxiosInstance } from 'axios';
+import { createNewUserAxios } from './utils/axios-instance/new-user';
+import { getError } from './utils/get-error';
+import { initApp, permanentDeleteBase } from './utils/init-app';
+
+// Constants for reused strings
+const nonExistentId = 'non-existent-node-id';
+const getTestFolder = 'Get Test Folder';
+const originalName = 'Original Name';
+const testFolder = 'Test Folder';
+const updatedName = 'Updated Name';
+const testTableName = 'Test Table';
+
+describe('BaseNodeController (e2e) /api/base/:baseId/node', () => {
+ let app: INestApplication;
+ let baseId: string;
+
+ beforeAll(async () => {
+ const appCtx = await initApp();
+ app = appCtx.app;
+ const base = await createBase({
+ name: 'test base node',
+ spaceId: globalThis.testConfig.spaceId,
+ }).then((res) => res.data);
+ baseId = base.id;
+ });
+
+ afterAll(async () => {
+ await permanentDeleteBase(baseId);
+ await app.close();
+ });
+
+ describe('GET /api/base/:baseId/node/tree - Get tree structure', () => {
+ it('should get base node tree successfully', async () => {
+ const response = await getBaseNodeTree(baseId);
+
+ expect(response.data).toBeDefined();
+ expect(response.data).toHaveProperty('nodes');
+ expect(Array.isArray(response.data.nodes)).toBe(true);
+ });
+
+ it('should return tree with correct structure', async () => {
+ // Create a test node
+ const node = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Tree Test Folder',
+ });
+
+ const response = await getBaseNodeTree(baseId);
+ const createdNode = response.data.nodes.find((n: IBaseNodeVo) => n.id === node.data.id);
+
+ expect(createdNode).toBeDefined();
+ expect(createdNode?.resourceMeta?.name).toBe('Tree Test Folder');
+ expect(createdNode?.resourceType).toBe(BaseNodeResourceType.Folder);
+
+ // Cleanup
+ await deleteBaseNode(baseId, node.data.id);
+ });
+ });
+
+ describe('GET /api/base/:baseId/node/:nodeId - Get single node', () => {
+ let testNodeId: string;
+
+ beforeEach(async () => {
+ const node = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: getTestFolder,
+ });
+ testNodeId = node.data.id;
+ });
+
+ afterEach(async () => {
+ await deleteBaseNode(baseId, testNodeId);
+ });
+
+ it('should get single node successfully', async () => {
+ const response = await getBaseNode(baseId, testNodeId);
+
+ expect(response.data).toBeDefined();
+ expect(response.data.id).toBe(testNodeId);
+ expect(response.data.resourceMeta?.name).toBe(getTestFolder);
+ expect(response.data.resourceType).toBe(BaseNodeResourceType.Folder);
+ });
+
+ it('should fail when node does not exist', async () => {
+ const error = await getError(() => getBaseNode(baseId, nonExistentId));
+
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('should fail when baseId and nodeId do not match', async () => {
+ const wrongBaseId = 'wrong-base-id';
+ const error = await getError(() => getBaseNode(wrongBaseId, testNodeId));
+
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+ });
+
+ describe('POST /api/base/:baseId/node - Create node', () => {
+ const nodesToCleanup: string[] = [];
+
+ afterEach(async () => {
+ // Cleanup created nodes
+ for (const nodeId of [...nodesToCleanup].reverse()) {
+ await deleteBaseNode(baseId, nodeId);
+ }
+ nodesToCleanup.length = 0;
+ });
+
+ it('should create a folder node successfully', async () => {
+ const response = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: testFolder,
+ });
+
+ expect(response.data).toBeDefined();
+ expect(response.data.resourceMeta?.name).toBe(testFolder);
+ expect(response.data.resourceType).toBe(BaseNodeResourceType.Folder);
+ expect(response.data.id).toBeDefined();
+
+ nodesToCleanup.push(response.data.id);
+ });
+
+ it('should create a table node successfully', async () => {
+ const response = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: testTableName,
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ const resourceMeta = response.data.resourceMeta as IBaseNodeTableResourceMeta;
+ expect(response.data).toBeDefined();
+ expect(resourceMeta.name).toBe(testTableName);
+ expect(resourceMeta.defaultViewId).toBeDefined();
+ expect(response.data.resourceType).toBe(BaseNodeResourceType.Table);
+ expect(response.data.resourceId).toBeDefined();
+
+ nodesToCleanup.push(response.data.id);
+ });
+
+ it('should create a dashboard node successfully', async () => {
+ const response = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Test Dashboard',
+ });
+
+ expect(response.data).toBeDefined();
+ expect(response.data.resourceMeta?.name).toBe('Test Dashboard');
+ expect(response.data.resourceType).toBe(BaseNodeResourceType.Dashboard);
+ expect(response.data.resourceId).toBeDefined();
+
+ nodesToCleanup.push(response.data.id);
+ });
+
+ it('should create nested node with parentId', async () => {
+ // Create parent folder
+ const parent = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Parent Folder',
+ });
+ nodesToCleanup.push(parent.data.id);
+
+ // Create child node
+ const child = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child Folder',
+ parentId: parent.data.id,
+ });
+ nodesToCleanup.push(child.data.id);
+
+ expect(child.data.parentId).toBe(parent.data.id);
+
+ // Verify in tree
+ const tree = await getBaseNodeTree(baseId);
+ const parentNode = tree.data.nodes.find((n: IBaseNodeVo) => n.id === parent.data.id);
+ expect(parentNode?.children).toBeDefined();
+ expect(parentNode?.children?.some((c) => c.id === child.data.id)).toBe(true);
+ });
+
+ it('should trim node name', async () => {
+ const response = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: ' Trimmed Name ',
+ });
+
+ expect(response.data.resourceMeta?.name).toBe('Trimmed Name');
+ nodesToCleanup.push(response.data.id);
+ });
+
+ it('should fail with empty name', async () => {
+ const error = await getError(() =>
+ createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: '',
+ })
+ );
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should fail with whitespace only name', async () => {
+ const error = await getError(() =>
+ createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: ' ',
+ })
+ );
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should fail when parent node does not exist', async () => {
+ const error = await getError(() =>
+ createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Test Folder',
+ parentId: 'non-existent-parent-id',
+ })
+ );
+
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('should fail when parent node is not folder type', async () => {
+ const node = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: testTableName,
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+
+ nodesToCleanup.push(node.data.id);
+
+ const error = await getError(() =>
+ createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: testTableName,
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ parentId: node.data.id,
+ })
+ );
+
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+ });
+
+ describe('PUT /api/base/:baseId/node/:nodeId - Update node', () => {
+ let testNodeId: string;
+
+ beforeEach(async () => {
+ const node = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: originalName,
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ testNodeId = node.data.id;
+ });
+
+ afterEach(async () => {
+ await deleteBaseNode(baseId, testNodeId);
+ });
+
+ it('should update node name successfully', async () => {
+ const response = await updateBaseNode(baseId, testNodeId, {
+ name: updatedName,
+ });
+
+ expect(response.data.resourceMeta?.name).toBe(updatedName);
+ expect(response.data.id).toBe(testNodeId);
+ });
+
+ it('should update node icon successfully', async () => {
+ const response = await updateBaseNode(baseId, testNodeId, {
+ icon: '📁',
+ });
+
+ expect(response.data.resourceMeta?.icon).toBe('📁');
+ expect(response.data.id).toBe(testNodeId);
+ });
+
+ it('should update both name and icon', async () => {
+ const response = await updateBaseNode(baseId, testNodeId, {
+ name: updatedName,
+ icon: '🎯',
+ });
+
+ expect(response.data.resourceMeta?.name).toBe(updatedName);
+ expect(response.data.resourceMeta?.icon).toBe('🎯');
+ });
+
+ it('should trim name when updating', async () => {
+ const response = await updateBaseNode(baseId, testNodeId, {
+ name: ' Trimmed Updated ',
+ });
+
+ expect(response.data.resourceMeta?.name).toBe('Trimmed Updated');
+ });
+
+ it('should handle empty update object', async () => {
+ const response = await updateBaseNode(baseId, testNodeId, {});
+
+ expect(response.data.id).toBe(testNodeId);
+ expect(response.data.resourceMeta?.name).toBe(originalName);
+ });
+
+ it('should fail when updating non-existent node', async () => {
+ const error = await getError(() =>
+ updateBaseNode(baseId, nonExistentId, { name: 'New Name' })
+ );
+
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('should fail with empty name', async () => {
+ const error = await getError(() => updateBaseNode(baseId, testNodeId, { name: '' }));
+
+ expect(error?.status).toBe(400);
+ });
+ });
+
+ describe('DELETE /api/base/:baseId/node/:nodeId - Delete node', () => {
+ it('should delete leaf node successfully', async () => {
+ // Create a node
+ const node = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'To Delete',
+ });
+
+ // Delete it
+ await deleteBaseNode(baseId, node.data.id);
+
+ // Verify it's deleted
+ const error = await getError(() => getBaseNode(baseId, node.data.id));
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('should fail when deleting non-existent node', async () => {
+ const error = await getError(() => deleteBaseNode(baseId, nonExistentId));
+
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('should handle deletion of already deleted node', async () => {
+ const node = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Temp Node',
+ });
+
+ // Delete once
+ await deleteBaseNode(baseId, node.data.id);
+
+ // Try to delete again
+ const error = await getError(() => deleteBaseNode(baseId, node.data.id));
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('should fail when delete folder node with children', async () => {
+ const folder = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder',
+ }).then((res) => res.data);
+
+ await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child',
+ parentId: folder.id,
+ }).then((res) => res.data.id);
+
+ // Verify it's deleted
+ const error = await getError(() => deleteBaseNode(baseId, folder.id));
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+ });
+
+ describe('PUT /api/base/:baseId/node/:nodeId/move - Move node', () => {
+ const nodesToCleanup: string[] = [];
+
+ afterEach(async () => {
+ for (const nodeId of [...nodesToCleanup].reverse()) {
+ await deleteBaseNode(baseId, nodeId);
+ }
+ nodesToCleanup.length = 0;
+ });
+
+ it('should move node to another folder', async () => {
+ // Create nodes
+ const folder1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder 1',
+ });
+ nodesToCleanup.push(folder1.data.id);
+
+ const folder2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder 2',
+ });
+ nodesToCleanup.push(folder2.data.id);
+
+ const node = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Node to Move',
+ parentId: folder1.data.id,
+ });
+ nodesToCleanup.push(node.data.id);
+
+ // Move node to folder2
+ const response = await moveBaseNode(baseId, node.data.id, {
+ parentId: folder2.data.id,
+ });
+
+ expect(response.data.parentId).toBe(folder2.data.id);
+ });
+
+ it('should move node to root level', async () => {
+ // Create parent folder and child
+ const parent = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Parent',
+ });
+ nodesToCleanup.push(parent.data.id);
+
+ const child = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child',
+ parentId: parent.data.id,
+ });
+ nodesToCleanup.push(child.data.id);
+
+ // Move to root
+ const response = await moveBaseNode(baseId, child.data.id, {
+ parentId: null,
+ });
+
+ expect(response.data.parentId).toBeNull();
+ });
+
+ it('should reorder nodes using anchorId and position', async () => {
+ // Create multiple nodes at root level
+ const node1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Node 1',
+ });
+ nodesToCleanup.push(node1.data.id);
+
+ const node2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Node 2',
+ });
+ nodesToCleanup.push(node2.data.id);
+
+ const node3 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Node 3',
+ });
+ nodesToCleanup.push(node3.data.id);
+
+ // Move node3 before node1
+ const response = await moveBaseNode(baseId, node3.data.id, {
+ anchorId: node1.data.id,
+ position: 'before',
+ });
+
+ expect(response.data).toBeDefined();
+ expect(response.data.id).toBe(node3.data.id);
+ });
+
+ it('should reorder nodes using position before and anchorId same parent', async () => {
+ // Create a parent folder
+ const parent = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Parent Folder',
+ });
+ nodesToCleanup.push(parent.data.id);
+
+ // Create multiple child nodes under same parent
+ const child1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child 1',
+ parentId: parent.data.id,
+ });
+ nodesToCleanup.push(child1.data.id);
+
+ const child2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child 2',
+ parentId: parent.data.id,
+ });
+ nodesToCleanup.push(child2.data.id);
+
+ const child3 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child 3',
+ parentId: parent.data.id,
+ });
+ nodesToCleanup.push(child3.data.id);
+
+ // Move child3 before child1 (both have same parent)
+ const response = await moveBaseNode(baseId, child3.data.id, {
+ anchorId: child1.data.id,
+ position: 'before',
+ });
+
+ expect(response.data).toBeDefined();
+ expect(response.data.id).toBe(child3.data.id);
+ expect(response.data.parentId).toBe(parent.data.id);
+ });
+
+ it('should reorder nodes using position after', async () => {
+ const node1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Node A',
+ });
+ nodesToCleanup.push(node1.data.id);
+
+ const node2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Node B',
+ });
+ nodesToCleanup.push(node2.data.id);
+
+ // Move node1 after node2
+ const response = await moveBaseNode(baseId, node1.data.id, {
+ anchorId: node2.data.id,
+ position: 'after',
+ });
+
+ expect(response.data.id).toBe(node1.data.id);
+ });
+
+ it('should reorder nodes using position after and anchorId same parent', async () => {
+ // Create a parent folder
+ const parent = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Parent Container',
+ });
+ nodesToCleanup.push(parent.data.id);
+
+ // Create multiple child nodes under same parent
+ const childA = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child A',
+ parentId: parent.data.id,
+ });
+ nodesToCleanup.push(childA.data.id);
+
+ const childB = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child B',
+ parentId: parent.data.id,
+ });
+ nodesToCleanup.push(childB.data.id);
+
+ const childC = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child C',
+ parentId: parent.data.id,
+ });
+ nodesToCleanup.push(childC.data.id);
+
+ // Move childA after childC (both have same parent)
+ const response = await moveBaseNode(baseId, childA.data.id, {
+ anchorId: childC.data.id,
+ position: 'after',
+ });
+
+ expect(response.data).toBeDefined();
+ expect(response.data.id).toBe(childA.data.id);
+ expect(response.data.parentId).toBe(parent.data.id);
+ });
+
+ it('should fail when moving node to itself', async () => {
+ const node = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Self Reference Node',
+ });
+ nodesToCleanup.push(node.data.id);
+
+ const error = await getError(() =>
+ moveBaseNode(baseId, node.data.id, {
+ parentId: node.data.id,
+ })
+ );
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should fail when moving node to its own child (circular reference)', async () => {
+ // Create parent and child
+ const parent = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Parent',
+ });
+ nodesToCleanup.push(parent.data.id);
+
+ const child = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child',
+ parentId: parent.data.id,
+ });
+ nodesToCleanup.push(child.data.id);
+
+ // Try to move parent into child (circular reference)
+ const error = await getError(() =>
+ moveBaseNode(baseId, parent.data.id, {
+ parentId: child.data.id,
+ })
+ );
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should fail when anchor node does not exist', async () => {
+ const node = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Test Node',
+ });
+ nodesToCleanup.push(node.data.id);
+
+ const error = await getError(() =>
+ moveBaseNode(baseId, node.data.id, {
+ anchorId: 'non-existent-anchor',
+ position: 'before',
+ })
+ );
+
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+
+ it('should fail when parent node does not folder type', async () => {
+ // Create a table node (non-folder type)
+ const table = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Non-Folder Parent',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ nodesToCleanup.push(table.data.id);
+
+ // Create a folder node
+ const folder = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder Node',
+ });
+ nodesToCleanup.push(folder.data.id);
+
+ // Try to move folder under table (should fail because table is not a folder)
+ const error = await getError(() =>
+ moveBaseNode(baseId, folder.data.id, {
+ parentId: table.data.id,
+ })
+ );
+
+ expect(error?.status).toBe(400);
+ });
+ });
+
+ describe('POST /api/base/:baseId/node/:nodeId/duplicate - Duplicate node', () => {
+ const nodesToCleanup: string[] = [];
+
+ afterEach(async () => {
+ for (const nodeId of [...nodesToCleanup].reverse()) {
+ await deleteBaseNode(baseId, nodeId);
+ }
+ nodesToCleanup.length = 0;
+ });
+
+ it('should duplicate folder fail', async () => {
+ const original = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Original Folder',
+ });
+ nodesToCleanup.push(original.data.id);
+
+ const error = await getError(() =>
+ duplicateBaseNode(baseId, original.data.id, {
+ name: 'Duplicated Folder',
+ })
+ );
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should duplicate table successfully', async () => {
+ const original = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Original Table',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ nodesToCleanup.push(original.data.id);
+
+ const duplicate = await duplicateBaseNode(baseId, original.data.id, {
+ name: 'Duplicated Table',
+ });
+ nodesToCleanup.push(duplicate.data.id);
+
+ expect(duplicate.data.id).not.toBe(original.data.id);
+ expect(duplicate.data.resourceId).not.toBe(original.data.resourceId);
+ expect(duplicate.data.resourceMeta?.name).toBe('Duplicated Table');
+ });
+
+ it('should duplicate dashboard successfully', async () => {
+ const original = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Original Dashboard',
+ });
+ nodesToCleanup.push(original.data.id);
+
+ const duplicate = await duplicateBaseNode(baseId, original.data.id, {
+ name: 'Duplicated Dashboard',
+ });
+ nodesToCleanup.push(duplicate.data.id);
+
+ expect(duplicate.data.id).not.toBe(original.data.id);
+ expect(duplicate.data.resourceMeta?.name).toBe('Duplicated Dashboard');
+ });
+
+ it('should fail when duplicating non-existent node', async () => {
+ const error = await getError(() =>
+ duplicateBaseNode(baseId, nonExistentId, { name: 'Duplicate' })
+ );
+
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+ });
+ });
+
+ describe('Integration scenarios', () => {
+ const nodesToCleanup: string[] = [];
+
+ afterEach(async () => {
+ for (const nodeId of [...nodesToCleanup].reverse()) {
+ await deleteBaseNode(baseId, nodeId);
+ }
+ nodesToCleanup.length = 0;
+ });
+
+ it('should handle complete CRUD lifecycle', async () => {
+ // Create
+ const created = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Lifecycle Test',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ expect(created.data.resourceMeta?.name).toBe('Lifecycle Test');
+ nodesToCleanup.push(created.data.id);
+
+ // Read
+ const read = await getBaseNode(baseId, created.data.id);
+ expect(read.data.id).toBe(created.data.id);
+
+ // Update
+ const updated = await updateBaseNode(baseId, created.data.id, {
+ name: 'Updated Lifecycle Test',
+ icon: '🔄',
+ });
+ expect(updated.data.resourceMeta?.name).toBe('Updated Lifecycle Test');
+ expect(updated.data.resourceMeta?.icon).toBe('🔄');
+
+ // Delete
+ await deleteBaseNode(baseId, created.data.id);
+ const error = await getError(() => getBaseNode(baseId, created.data.id));
+ expect(error?.status).toBeGreaterThanOrEqual(400);
+
+ // Remove from cleanup since already deleted
+ nodesToCleanup.pop();
+ });
+
+ it('should handle complex folder hierarchy', async () => {
+ const root = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Root',
+ });
+ nodesToCleanup.push(root.data.id);
+
+ const child1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child 1',
+ parentId: root.data.id,
+ });
+ nodesToCleanup.push(child1.data.id);
+
+ const child2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Child 2',
+ parentId: root.data.id,
+ });
+ nodesToCleanup.push(child2.data.id);
+
+ const child1Table = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Child 1 Table',
+ parentId: child1.data.id,
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ nodesToCleanup.push(child1Table.data.id);
+
+ // Verify structure
+ const tree = await getBaseNodeTree(baseId);
+ const rootNode = tree.data.nodes.find((n: IBaseNodeVo) => n.id === root.data.id);
+
+ expect(rootNode?.children).toHaveLength(2);
+ const child1Node = tree.data.nodes.find((n: IBaseNodeVo) => n.id === child1.data.id);
+ expect(child1Node?.children).toHaveLength(1);
+ });
+
+ it('should handle moving nodes between folders', async () => {
+ // Create structure: Folder A with Child, Folder B empty
+ const folderA = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder A',
+ });
+ nodesToCleanup.push(folderA.data.id);
+
+ const folderB = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder B',
+ });
+ nodesToCleanup.push(folderB.data.id);
+
+ const child = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Movable Table',
+ parentId: folderA.data.id,
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ nodesToCleanup.push(child.data.id);
+
+ // Verify initial state
+ let node = await getBaseNode(baseId, child.data.id);
+ expect(node.data.parentId).toBe(folderA.data.id);
+
+ // Move to Folder B
+ await moveBaseNode(baseId, child.data.id, {
+ parentId: folderB.data.id,
+ });
+
+ // Verify moved
+ node = await getBaseNode(baseId, child.data.id);
+ expect(node.data.parentId).toBe(folderB.data.id);
+
+ // Move to root
+ await moveBaseNode(baseId, child.data.id, {
+ parentId: null,
+ });
+
+ // Verify at root
+ node = await getBaseNode(baseId, child.data.id);
+ expect(node.data.parentId).toBeNull();
+ });
+
+ it('should maintain order when creating and moving nodes', async () => {
+ // Create multiple nodes
+ const nodes = [];
+ for (let i = 1; i <= 3; i++) {
+ const node = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: `Order Test ${i}`,
+ });
+ nodes.push(node.data);
+ nodesToCleanup.push(node.data.id);
+ }
+
+ // Get tree and verify all nodes exist
+ const tree = await getBaseNodeTree(baseId);
+ for (const node of nodes) {
+ const found = tree.data.nodes.find((n: IBaseNodeVo) => n.id === node.id);
+ expect(found).toBeDefined();
+ }
+ });
+ });
+
+ describe('Folder depth limitation', () => {
+ const nodesToCleanup: string[] = [];
+
+ afterEach(async () => {
+ // Cleanup nodes in reverse order to handle hierarchy
+ for (const nodeId of [...nodesToCleanup].reverse()) {
+ await deleteBaseNode(baseId, nodeId);
+ }
+ nodesToCleanup.length = 0;
+ });
+
+ it('should allow creating folders up to max depth (3 levels)', async () => {
+ // Create level 1 folder
+ const level1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Level 1 Folder',
+ });
+ nodesToCleanup.push(level1.data.id);
+ expect(level1.data.parentId).toBeNull();
+
+ // Create level 2 folder
+ const level2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Level 2 Folder',
+ parentId: level1.data.id,
+ });
+ nodesToCleanup.push(level2.data.id);
+ expect(level2.data.parentId).toBe(level1.data.id);
+ });
+
+ it('should fail when creating folder exceeding max depth (4th level)', async () => {
+ // Create 3 levels of folders
+ const level1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Depth Limit Level 1',
+ });
+ nodesToCleanup.push(level1.data.id);
+
+ const level2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Depth Limit Level 2',
+ parentId: level1.data.id,
+ });
+ nodesToCleanup.push(level2.data.id);
+
+ // Try to create level 4 folder (should fail)
+ const error = await getError(() =>
+ createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Depth Limit Level 3',
+ parentId: level2.data.id,
+ })
+ );
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should allow creating table in folder at max depth', async () => {
+ // Create 2 levels of folders
+ const level1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Table Depth Level 1',
+ });
+ nodesToCleanup.push(level1.data.id);
+
+ const level2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Table Depth Level 2',
+ parentId: level1.data.id,
+ });
+ nodesToCleanup.push(level2.data.id);
+
+ // Create table in level 2 folder (should succeed - tables don't count as depth)
+ const table = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Table in Max Depth',
+ parentId: level2.data.id,
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ nodesToCleanup.push(table.data.id);
+ expect(table.data.parentId).toBe(level2.data.id);
+ });
+
+ it('should fail when moving folder to exceed max depth using anchorId', async () => {
+ // Create 3 levels of folders
+ const level1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Move Depth Level 1',
+ });
+ nodesToCleanup.push(level1.data.id);
+
+ const level2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Move Depth Level 2',
+ parentId: level1.data.id,
+ });
+ nodesToCleanup.push(level2.data.id);
+
+ const level3 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Table in Move Depth Level 3',
+ parentId: level2.data.id,
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ nodesToCleanup.push(level3.data.id);
+
+ // Create a folder at root level to move
+ const folderToMove = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder to Move',
+ });
+ nodesToCleanup.push(folderToMove.data.id);
+
+ // Try to move folder next to level2 (which would make it level 3 if it had the same parent)
+ // Using anchorId with position should check depth
+ const error = await getError(() =>
+ moveBaseNode(baseId, folderToMove.data.id, {
+ anchorId: level3.data.id,
+ position: 'after',
+ })
+ );
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should fail when moving folder to another folder exceeds max depth using parentId', async () => {
+ // Create 2 levels of folders (max depth)
+ const level1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Parent Move Depth Level 1',
+ });
+ nodesToCleanup.push(level1.data.id);
+
+ const level2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Parent Move Depth Level 2',
+ parentId: level1.data.id,
+ });
+ nodesToCleanup.push(level2.data.id);
+
+ // Create a folder at root level to move
+ const folderToMove = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder to Move Into Depth',
+ });
+ nodesToCleanup.push(folderToMove.data.id);
+
+ // Try to move folder into level2 using parentId (would exceed max depth)
+ const error = await getError(() =>
+ moveBaseNode(baseId, folderToMove.data.id, {
+ parentId: level2.data.id,
+ })
+ );
+
+ expect(error?.status).toBe(400);
+ });
+
+ it('should allow moving folder within valid depth using anchorId', async () => {
+ // Create 2 levels of folders
+ const level1 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Valid Move Level 1',
+ });
+ nodesToCleanup.push(level1.data.id);
+
+ const level2 = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Valid Move Level 2',
+ parentId: level1.data.id,
+ });
+ nodesToCleanup.push(level2.data.id);
+
+ // Create a folder at root level
+ const folderToMove = await createBaseNode(baseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder to Move Valid',
+ });
+ nodesToCleanup.push(folderToMove.data.id);
+
+ // Move folder next to level2 (which makes it level 3 - still valid)
+ const response = await moveBaseNode(baseId, folderToMove.data.id, {
+ anchorId: level2.data.id,
+ position: 'after',
+ });
+
+ expect(response.data.id).toBe(folderToMove.data.id);
+ expect(response.data.parentId).toBe(level1.data.id);
+ });
+
+ it('should return maxFolderDepth in tree response', async () => {
+ const response = await getBaseNodeTree(baseId);
+
+ expect(response.data).toHaveProperty('maxFolderDepth');
+ expect(response.data.maxFolderDepth).toBe(2);
+ });
+ });
+
+ describe('Permission tests', () => {
+ let permissionSpaceId: string;
+ let permissionBaseId: string;
+ let viewerAxios: AxiosInstance;
+ let creatorAxios: AxiosInstance;
+ let nonCollaboratorAxios: AxiosInstance;
+ const nodesToCleanup: string[] = [];
+
+ const viewerEmail = 'base-node-viewer@test.com';
+ const creatorEmail = 'base-node-creator@test.com';
+ const nonCollaboratorEmail = 'base-node-non-collaborator@test.com';
+
+ beforeAll(async () => {
+ // Create a new space and base for permission tests
+ const space = await apiCreateSpace({ name: 'Permission Test Space' }).then((res) => res.data);
+ permissionSpaceId = space.id;
+
+ const base = await createBase({
+ name: 'Permission Test Base',
+ spaceId: permissionSpaceId,
+ }).then((res) => res.data);
+ permissionBaseId = base.id;
+
+ // Create test users
+ viewerAxios = await createNewUserAxios({
+ email: viewerEmail,
+ password: '12345678',
+ });
+
+ creatorAxios = await createNewUserAxios({
+ email: creatorEmail,
+ password: '12345678',
+ });
+
+ nonCollaboratorAxios = await createNewUserAxios({
+ email: nonCollaboratorEmail,
+ password: '12345678',
+ });
+
+ // Invite viewer with Viewer role (read-only)
+ await emailBaseInvitation({
+ baseId: permissionBaseId,
+ emailBaseInvitationRo: {
+ emails: [viewerEmail],
+ role: Role.Viewer,
+ },
+ });
+
+ // Invite creator with Creator role (full access)
+ await emailBaseInvitation({
+ baseId: permissionBaseId,
+ emailBaseInvitationRo: {
+ emails: [creatorEmail],
+ role: Role.Creator,
+ },
+ });
+ });
+
+ afterAll(async () => {
+ // Cleanup nodes first
+ for (const nodeId of [...nodesToCleanup].reverse()) {
+ await deleteBaseNode(permissionBaseId, nodeId);
+ }
+ // Then delete the space (which will delete the base)
+ await apiPermanentDeleteSpace(permissionSpaceId);
+ });
+
+ describe('Non-collaborator access', () => {
+ it('should fail to get node list when user is not a collaborator', async () => {
+ const error = await getError(() =>
+ nonCollaboratorAxios.get(urlBuilder(GET_BASE_NODE_LIST, { baseId: permissionBaseId }))
+ );
+ expect(error?.status).toBe(403);
+ });
+
+ it('should fail to get node tree when user is not a collaborator', async () => {
+ const error = await getError(() =>
+ nonCollaboratorAxios.get(urlBuilder(GET_BASE_NODE_TREE, { baseId: permissionBaseId }))
+ );
+ expect(error?.status).toBe(403);
+ });
+
+ it('should fail to create node when user is not a collaborator', async () => {
+ const error = await getError(() =>
+ nonCollaboratorAxios.post(urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }), {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Unauthorized Folder',
+ })
+ );
+ expect(error?.status).toBe(403);
+ });
+ });
+
+ describe('Viewer role permissions', () => {
+ let testFolderId: string;
+ let testTableId: string;
+ let testDashboardId: string;
+
+ beforeAll(async () => {
+ // Create test nodes as owner for viewer to test against
+ const folder = await createBaseNode(permissionBaseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Viewer Test Folder',
+ });
+ testFolderId = folder.data.id;
+ nodesToCleanup.push(testFolderId);
+
+ const table = await createBaseNode(permissionBaseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Viewer Test Table',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ testTableId = table.data.id;
+ nodesToCleanup.push(testTableId);
+
+ const dashboard = await createBaseNode(permissionBaseId, {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Viewer Test Dashboard',
+ });
+ testDashboardId = dashboard.data.id;
+ nodesToCleanup.push(testDashboardId);
+ });
+
+ it('should allow viewer to get node list', async () => {
+ const response = await viewerAxios.get(
+ urlBuilder(GET_BASE_NODE_LIST, { baseId: permissionBaseId })
+ );
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.data)).toBe(true);
+ });
+
+ it('should allow viewer to get node tree', async () => {
+ const response = await viewerAxios.get(
+ urlBuilder(GET_BASE_NODE_TREE, { baseId: permissionBaseId })
+ );
+ expect(response.status).toBe(200);
+ expect(response.data).toHaveProperty('nodes');
+ });
+
+ it('should allow viewer to get single folder node', async () => {
+ const response = await viewerAxios.get(
+ urlBuilder(GET_BASE_NODE, { baseId: permissionBaseId, nodeId: testFolderId })
+ );
+ expect(response.status).toBe(200);
+ expect(response.data.id).toBe(testFolderId);
+ });
+
+ it('should allow viewer to get single table node', async () => {
+ const response = await viewerAxios.get(
+ urlBuilder(GET_BASE_NODE, { baseId: permissionBaseId, nodeId: testTableId })
+ );
+ expect(response.status).toBe(200);
+ expect(response.data.id).toBe(testTableId);
+ });
+
+ it('should allow viewer to get single dashboard node', async () => {
+ const response = await viewerAxios.get(
+ urlBuilder(GET_BASE_NODE, { baseId: permissionBaseId, nodeId: testDashboardId })
+ );
+ expect(response.status).toBe(200);
+ expect(response.data.id).toBe(testDashboardId);
+ });
+
+ it('should deny viewer from creating folder node', async () => {
+ const error = await getError(() =>
+ viewerAxios.post(urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }), {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Viewer Created Folder',
+ })
+ );
+ expect(error?.status).toBe(403);
+ });
+
+ it('should deny viewer from creating table node', async () => {
+ // Viewer doesn't have table|create permission
+ const error = await getError(() =>
+ viewerAxios.post(urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }), {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Viewer Table',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ })
+ );
+ expect(error?.status).toBe(403);
+ });
+
+ it('should deny viewer from creating dashboard node', async () => {
+ // Viewer doesn't have base|update permission required for Dashboard creation
+ const error = await getError(() =>
+ viewerAxios.post(urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }), {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Viewer Dashboard',
+ })
+ );
+ expect(error?.status).toBe(403);
+ });
+
+ it('should deny viewer from updating table node', async () => {
+ // Viewer doesn't have table|update permission
+ const error = await getError(() =>
+ viewerAxios.put(
+ urlBuilder(UPDATE_BASE_NODE, { baseId: permissionBaseId, nodeId: testTableId }),
+ { name: 'Viewer Updated Table' }
+ )
+ );
+ expect(error?.status).toBe(403);
+ });
+
+ it('should deny viewer from updating dashboard node', async () => {
+ // Viewer doesn't have base|update permission
+ const error = await getError(() =>
+ viewerAxios.put(
+ urlBuilder(UPDATE_BASE_NODE, { baseId: permissionBaseId, nodeId: testDashboardId }),
+ { name: 'Viewer Updated Dashboard' }
+ )
+ );
+ expect(error?.status).toBe(403);
+ });
+
+ it('should deny viewer from deleting table node', async () => {
+ // Viewer doesn't have table|delete permission
+ const error = await getError(() =>
+ viewerAxios.delete(
+ urlBuilder(DELETE_BASE_NODE, { baseId: permissionBaseId, nodeId: testTableId })
+ )
+ );
+ expect(error?.status).toBe(403);
+ });
+
+ it('should deny viewer from deleting dashboard node', async () => {
+ // Viewer doesn't have base|update permission
+ const error = await getError(() =>
+ viewerAxios.delete(
+ urlBuilder(DELETE_BASE_NODE, { baseId: permissionBaseId, nodeId: testDashboardId })
+ )
+ );
+ expect(error?.status).toBe(403);
+ });
+
+ it('should deny viewer from moving node (requires base|update)', async () => {
+ // Move operation requires base|update permission
+ const error = await getError(() =>
+ viewerAxios.put(
+ urlBuilder(MOVE_BASE_NODE, { baseId: permissionBaseId, nodeId: testTableId }),
+ { parentId: testFolderId }
+ )
+ );
+ expect(error?.status).toBe(403);
+ });
+
+ it('should deny viewer from duplicating table node', async () => {
+ // Duplicate requires base_node|read and base_node|create
+ // For table, create requires table|create which viewer doesn't have
+ const error = await getError(() =>
+ viewerAxios.post(
+ urlBuilder(DUPLICATE_BASE_NODE, { baseId: permissionBaseId, nodeId: testTableId }),
+ { name: 'Duplicated Table' }
+ )
+ );
+ expect(error?.status).toBe(403);
+ });
+ });
+
+ describe('Creator role permissions', () => {
+ const creatorNodesToCleanup: string[] = [];
+
+ afterEach(async () => {
+ for (const nodeId of [...creatorNodesToCleanup].reverse()) {
+ await deleteBaseNode(permissionBaseId, nodeId);
+ }
+ creatorNodesToCleanup.length = 0;
+ });
+
+ it('should allow creator to get node list', async () => {
+ const response = await creatorAxios.get(
+ urlBuilder(GET_BASE_NODE_LIST, { baseId: permissionBaseId })
+ );
+ expect(response.status).toBe(200);
+ });
+
+ it('should allow creator to get node tree', async () => {
+ const response = await creatorAxios.get(
+ urlBuilder(GET_BASE_NODE_TREE, { baseId: permissionBaseId })
+ );
+ expect(response.status).toBe(200);
+ });
+
+ it('should allow creator to create folder node', async () => {
+ const response = await creatorAxios.post(
+ urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),
+ {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Creator Folder',
+ }
+ );
+ expect(response.status).toBe(201);
+ expect(response.data.resourceMeta?.name).toBe('Creator Folder');
+ creatorNodesToCleanup.push(response.data.id);
+ });
+
+ it('should allow creator to create table node', async () => {
+ const response = await creatorAxios.post(
+ urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),
+ {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Creator Table',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ }
+ );
+ expect(response.status).toBe(201);
+ expect(response.data.resourceMeta?.name).toBe('Creator Table');
+ creatorNodesToCleanup.push(response.data.id);
+ });
+
+ it('should allow creator to create dashboard node', async () => {
+ const response = await creatorAxios.post(
+ urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),
+ {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Creator Dashboard',
+ }
+ );
+ expect(response.status).toBe(201);
+ expect(response.data.resourceMeta?.name).toBe('Creator Dashboard');
+ creatorNodesToCleanup.push(response.data.id);
+ });
+
+ it('should allow creator to update table node', async () => {
+ const table = await creatorAxios.post(
+ urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),
+ {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Table to Update',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ }
+ );
+ creatorNodesToCleanup.push(table.data.id);
+
+ const response = await creatorAxios.put(
+ urlBuilder(UPDATE_BASE_NODE, { baseId: permissionBaseId, nodeId: table.data.id }),
+ { name: 'Updated Table Name' }
+ );
+ expect(response.status).toBe(200);
+ expect(response.data.resourceMeta?.name).toBe('Updated Table Name');
+ });
+
+ it('should allow creator to delete table node', async () => {
+ const table = await creatorAxios.post(
+ urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),
+ {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Table to Delete',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ }
+ );
+
+ const response = await creatorAxios.delete(
+ urlBuilder(DELETE_BASE_NODE, { baseId: permissionBaseId, nodeId: table.data.id })
+ );
+ expect(response.status).toBe(200);
+ });
+
+ it('should allow creator to move node', async () => {
+ const folder = await creatorAxios.post(
+ urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),
+ {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Move Target Folder',
+ }
+ );
+ creatorNodesToCleanup.push(folder.data.id);
+
+ const table = await creatorAxios.post(
+ urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),
+ {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Table to Move',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ }
+ );
+ creatorNodesToCleanup.push(table.data.id);
+
+ const response = await creatorAxios.put(
+ urlBuilder(MOVE_BASE_NODE, { baseId: permissionBaseId, nodeId: table.data.id }),
+ { parentId: folder.data.id }
+ );
+ expect(response.status).toBe(200);
+ expect(response.data.parentId).toBe(folder.data.id);
+ });
+
+ it('should allow creator to duplicate table node', async () => {
+ const table = await creatorAxios.post(
+ urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),
+ {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Table to Duplicate',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ }
+ );
+ creatorNodesToCleanup.push(table.data.id);
+
+ const response = await creatorAxios.post(
+ urlBuilder(DUPLICATE_BASE_NODE, { baseId: permissionBaseId, nodeId: table.data.id }),
+ { name: 'Duplicated Table' }
+ );
+ expect(response.status).toBe(201);
+ expect(response.data.resourceMeta?.name).toBe('Duplicated Table');
+ creatorNodesToCleanup.push(response.data.id);
+ });
+
+ it('should allow creator to duplicate dashboard node', async () => {
+ const dashboard = await creatorAxios.post(
+ urlBuilder(CREATE_BASE_NODE, { baseId: permissionBaseId }),
+ {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Dashboard to Duplicate',
+ }
+ );
+ creatorNodesToCleanup.push(dashboard.data.id);
+
+ const response = await creatorAxios.post(
+ urlBuilder(DUPLICATE_BASE_NODE, { baseId: permissionBaseId, nodeId: dashboard.data.id }),
+ { name: 'Duplicated Dashboard' }
+ );
+ expect(response.status).toBe(201);
+ expect(response.data.resourceMeta?.name).toBe('Duplicated Dashboard');
+ creatorNodesToCleanup.push(response.data.id);
+ });
+ });
+
+ describe('Permission filtering on list/tree endpoints', () => {
+ it('should filter nodes based on user permissions in list', async () => {
+ // Create nodes as owner
+ const folder = await createBaseNode(permissionBaseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Shared Folder',
+ });
+ nodesToCleanup.push(folder.data.id);
+
+ const table = await createBaseNode(permissionBaseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Shared Table',
+ fields: [{ name: 'Field1', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ });
+ nodesToCleanup.push(table.data.id);
+
+ // Viewer should see nodes they have permission to read
+ const viewerList = await viewerAxios.get(
+ urlBuilder(GET_BASE_NODE_LIST, { baseId: permissionBaseId })
+ );
+ expect(viewerList.status).toBe(200);
+
+ // Viewer has table|read so they should see the table
+ const viewerTableNode = viewerList.data.find((n: IBaseNodeVo) => n.id === table.data.id);
+ expect(viewerTableNode).toBeDefined();
+
+ // Viewer has base|read so they should see the folder (folder has no special permission)
+ const viewerFolderNode = viewerList.data.find((n: IBaseNodeVo) => n.id === folder.data.id);
+ expect(viewerFolderNode).toBeDefined();
+ });
+
+ it('should filter nodes based on user permissions in tree', async () => {
+ const folder = await createBaseNode(permissionBaseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Tree Test Folder',
+ });
+ nodesToCleanup.push(folder.data.id);
+
+ const dashboard = await createBaseNode(permissionBaseId, {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Tree Test Dashboard',
+ });
+ nodesToCleanup.push(dashboard.data.id);
+
+ // Viewer should see nodes in tree
+ const viewerTree = await viewerAxios.get(
+ urlBuilder(GET_BASE_NODE_TREE, { baseId: permissionBaseId })
+ );
+ expect(viewerTree.status).toBe(200);
+
+ // Viewer has base|read so they should see dashboard (dashboard read requires base|read)
+ const viewerDashboardNode = viewerTree.data.nodes.find(
+ (n: IBaseNodeVo) => n.id === dashboard.data.id
+ );
+ expect(viewerDashboardNode).toBeDefined();
+ });
+ });
+ });
+});
diff --git a/apps/nestjs-backend/test/import-base.e2e-spec.ts b/apps/nestjs-backend/test/import-base.e2e-spec.ts
index 7bc0775cca..a095c6411a 100644
--- a/apps/nestjs-backend/test/import-base.e2e-spec.ts
+++ b/apps/nestjs-backend/test/import-base.e2e-spec.ts
@@ -24,6 +24,10 @@ import {
getPluginPanel,
getPluginPanelPlugin,
getViewList,
+ createBaseNode,
+ getBaseNodeTree,
+ moveBaseNode,
+ BaseNodeResourceType,
} from '@teable/openapi';
import { pick } from 'lodash';
import type { ClsStore } from 'nestjs-cls';
@@ -842,4 +846,205 @@ describe('OpenAPI BaseController for base import (e2e)', () => {
expect(persistedMeta?.persistedAsGeneratedColumn).not.toBe(true);
});
});
+
+ describe('export and import the base with nodes [Folder, Table, Dashboard]', () => {
+ let nodeBaseId: string | undefined;
+ let importedNodeBaseId: string | undefined;
+ let awaitNodeExport: (fn: () => Promise) => Promise<{ previewUrl: string }>;
+
+ beforeAll(async () => {
+ awaitNodeExport = createAwaitWithEventWithResult<{ previewUrl: string }>(
+ app.get(EventEmitterService),
+ Events.BASE_EXPORT_COMPLETE
+ );
+ });
+
+ afterAll(async () => {
+ if (importedNodeBaseId) {
+ await permanentDeleteBase(importedNodeBaseId);
+ }
+ if (nodeBaseId) {
+ await permanentDeleteBase(nodeBaseId);
+ }
+ });
+
+ it('should export and import base with node hierarchy correctly', async () => {
+ // 1. Create source base with node hierarchy
+ const sourceBase = await createBase({
+ name: 'node_hierarchy_source',
+ spaceId,
+ icon: '📁',
+ }).then((res) => res.data);
+ nodeBaseId = sourceBase.id;
+
+ // Create folders using createBaseNode
+ const folder1Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder 1',
+ }).then((res) => res.data);
+ const folder2Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Folder,
+ name: 'Folder 2',
+ }).then((res) => res.data);
+
+ // Create tables using createBaseNode
+ const table1Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Table 1',
+ fields: [{ name: 'Title', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ }).then((res) => res.data);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const table2Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Table,
+ name: 'Table 2',
+ fields: [{ name: 'Name', type: FieldType.SingleLineText }],
+ views: [{ name: 'Grid view', type: ViewType.Grid }],
+ }).then((res) => res.data);
+
+ // Create dashboards using createBaseNode
+ const dashboard1Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Dashboard 1',
+ }).then((res) => res.data);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const dashboard2Node = await createBaseNode(nodeBaseId, {
+ resourceType: BaseNodeResourceType.Dashboard,
+ name: 'Dashboard 2',
+ }).then((res) => res.data);
+
+ // Move table1 into folder1 and dashboard1 into folder2
+ await moveBaseNode(nodeBaseId, table1Node.id, { parentId: folder1Node.id });
+ await moveBaseNode(nodeBaseId, dashboard1Node.id, { parentId: folder2Node.id });
+
+ // Get updated node tree
+ const updatedSourceNodeTree = await getBaseNodeTree(nodeBaseId).then((res) => res.data);
+ const updatedSourceNodes = updatedSourceNodeTree.nodes;
+
+ // 2. Export the base
+ const { previewUrl } = await awaitNodeExport(async () => {
+ await exportBase(nodeBaseId!);
+ });
+
+ // 3. Import the base
+ const attachmentService = getAttachmentService(app);
+ const clsService = app.get(ClsService);
+
+ const notify = await clsService.runWith>(
+ {
+ user: {
+ id: userId,
+ name: 'Test User',
+ email: 'test@example.com',
+ isAdmin: null,
+ },
+ } as unknown as ClsStore,
+ async () => {
+ return await attachmentService.uploadFromUrl(appUrl + previewUrl);
+ }
+ );
+
+ const { base: importedBase } = (
+ await importBase({
+ notify: notify as unknown as INotifyVo,
+ spaceId,
+ })
+ ).data;
+
+ importedNodeBaseId = importedBase.id;
+
+ // 4. Verify imported node tree
+ const importedNodeTree = await getBaseNodeTree(importedNodeBaseId).then((res) => res.data);
+ const importedNodes = importedNodeTree.nodes;
+
+ // Verify same number of nodes
+ expect(importedNodes.length).toBe(updatedSourceNodes.length);
+
+ // Verify resource types distribution
+ const sourceResourceTypes = updatedSourceNodes
+ .map((n) => n.resourceType)
+ .sort()
+ .join(',');
+ const importedResourceTypes = importedNodes
+ .map((n) => n.resourceType)
+ .sort()
+ .join(',');
+ expect(importedResourceTypes).toBe(sourceResourceTypes);
+
+ // Verify folder count
+ const sourceFolders = updatedSourceNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Folder
+ );
+ const importedFolders = importedNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Folder
+ );
+ expect(importedFolders.length).toBe(sourceFolders.length);
+
+ // Verify table count
+ const sourceTables = updatedSourceNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Table
+ );
+ const importedTables = importedNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Table
+ );
+ expect(importedTables.length).toBe(sourceTables.length);
+
+ // Verify dashboard count
+ const sourceDashboards = updatedSourceNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Dashboard
+ );
+ const importedDashboards = importedNodes.filter(
+ (n) => n.resourceType === BaseNodeResourceType.Dashboard
+ );
+ expect(importedDashboards.length).toBe(sourceDashboards.length);
+
+ // Verify hierarchy: nodes with parents should still have parents
+ const sourceNodesWithParent = updatedSourceNodes.filter((n) => n.parentId !== null);
+ const importedNodesWithParent = importedNodes.filter((n) => n.parentId !== null);
+ expect(importedNodesWithParent.length).toBe(sourceNodesWithParent.length);
+
+ // Verify folder names are preserved
+ const sourceFolderNames = sourceFolders.map((f) => f.resourceMeta?.name).sort();
+ const importedFolderNames = importedFolders.map((f) => f.resourceMeta?.name).sort();
+ expect(importedFolderNames).toEqual(sourceFolderNames);
+
+ // Verify that table inside folder1 exists in imported base
+ const importedFolder1 = importedFolders.find(
+ (f) => f.resourceMeta?.name === folder1Node.resourceMeta?.name
+ );
+ expect(importedFolder1).toBeDefined();
+ const tableInsideFolder = importedNodes.find((n) => {
+ return n.resourceType === BaseNodeResourceType.Table && n.parentId === importedFolder1!.id;
+ });
+ expect(tableInsideFolder).toBeDefined();
+
+ // Verify that dashboard inside folder2 exists in imported base
+ const importedFolder2 = importedFolders.find(
+ (f) => f.resourceMeta?.name === folder2Node.resourceMeta?.name
+ );
+ expect(importedFolder2).toBeDefined();
+ const dashboardInsideFolder = importedNodes.find((n) => {
+ return (
+ n.resourceType === BaseNodeResourceType.Dashboard && n.parentId === importedFolder2!.id
+ );
+ });
+ expect(dashboardInsideFolder).toBeDefined();
+
+ // Verify tables are accessible
+ const importedTableList = await getTableList(importedNodeBaseId).then((res) => res.data);
+ expect(importedTableList.length).toBe(2);
+ expect(importedTableList.map((t) => t.name).sort()).toEqual(
+ [table1Node.resourceMeta?.name, table2Node.resourceMeta?.name].sort()
+ );
+
+ // Verify dashboards are accessible
+ const importedDashboardList = await getDashboardList(importedNodeBaseId).then(
+ (res) => res.data
+ );
+ expect(importedDashboardList.length).toBe(2);
+ expect(importedDashboardList.map((d) => d.name).sort()).toEqual(
+ [dashboard1Node.resourceMeta?.name, dashboard2Node.resourceMeta?.name].sort()
+ );
+ });
+ });
});
diff --git a/apps/nestjs-backend/test/user-last-visit.e2e-spec.ts b/apps/nestjs-backend/test/user-last-visit.e2e-spec.ts
index f0931211db..37ab6573f8 100644
--- a/apps/nestjs-backend/test/user-last-visit.e2e-spec.ts
+++ b/apps/nestjs-backend/test/user-last-visit.e2e-spec.ts
@@ -10,6 +10,7 @@ import {
deleteBase,
deleteView,
getUserLastVisit,
+ getUserLastVisitBaseNode,
getUserLastVisitListBase,
getUserLastVisitMap,
LastVisitResourceType,
@@ -17,6 +18,7 @@ import {
updateUserLastVisit,
userLastVisitListBaseVoSchema,
} from '@teable/openapi';
+import { isEmpty } from 'lodash';
import { getViews, initApp, permanentDeleteBase } from './utils/init-app';
describe('OpenAPI OAuthController (e2e)', () => {
@@ -236,4 +238,113 @@ describe('OpenAPI OAuthController (e2e)', () => {
});
expect(userLastVisit.length).toEqual(0);
});
+
+ describe('getUserLastVisitBaseNode', () => {
+ let testBase: ICreateBaseVo;
+ let testTable: ITableFullVo;
+
+ beforeAll(async () => {
+ testBase = await createBase({
+ spaceId: globalThis.testConfig.spaceId,
+ name: 'base_node_test',
+ }).then((res) => res.data);
+ testTable = await createTable(testBase.id, { name: 'test_table' }).then((res) => res.data);
+ });
+
+ afterAll(async () => {
+ await permanentDeleteTable(testBase.id, testTable.id);
+ await permanentDeleteBase(testBase.id);
+ });
+
+ it('should return undefined when no visit record exists', async () => {
+ const newBase = await createBase({
+ spaceId: globalThis.testConfig.spaceId,
+ name: 'empty_base',
+ }).then((res) => res.data);
+
+ const res = await getUserLastVisitBaseNode({
+ parentResourceId: newBase.id,
+ }).then((res) => res.data);
+
+ expect(isEmpty(res)).toBe(true);
+
+ await permanentDeleteBase(newBase.id);
+ });
+
+ it('should return table visit after visiting a table', async () => {
+ await updateUserLastVisit({
+ resourceType: LastVisitResourceType.Table,
+ parentResourceId: testBase.id,
+ resourceId: testTable.id,
+ });
+
+ const res = await getUserLastVisitBaseNode({
+ parentResourceId: testBase.id,
+ });
+
+ expect(res.data).toEqual({
+ resourceId: testTable.id,
+ resourceType: LastVisitResourceType.Table,
+ });
+ });
+
+ it('should return most recent visit when multiple base nodes visited', async () => {
+ const table2 = await createTable(testBase.id, { name: 'test_table_2' }).then(
+ (res) => res.data
+ );
+
+ // Visit first table
+ await updateUserLastVisit({
+ resourceType: LastVisitResourceType.Table,
+ parentResourceId: testBase.id,
+ resourceId: testTable.id,
+ });
+
+ // Visit second table
+ await updateUserLastVisit({
+ resourceType: LastVisitResourceType.Table,
+ parentResourceId: testBase.id,
+ resourceId: table2.id,
+ });
+
+ const res = await getUserLastVisitBaseNode({
+ parentResourceId: testBase.id,
+ });
+
+ // Should return the most recent visit (table2)
+ expect(res.data).toEqual({
+ resourceId: table2.id,
+ resourceType: LastVisitResourceType.Table,
+ });
+
+ await permanentDeleteTable(testBase.id, table2.id);
+ });
+
+ it('should not include view visits in base node results', async () => {
+ // Clear previous visits by creating a fresh base
+ const freshBase = await createBase({
+ spaceId: globalThis.testConfig.spaceId,
+ name: 'fresh_base',
+ }).then((res) => res.data);
+ const freshTable = await createTable(freshBase.id, { name: 'fresh_table' }).then(
+ (res) => res.data
+ );
+
+ // Only visit a view (not a base node type)
+ await updateUserLastVisit({
+ resourceType: LastVisitResourceType.View,
+ parentResourceId: freshTable.id,
+ resourceId: freshTable.views[0].id,
+ });
+
+ const res = await getUserLastVisitBaseNode({
+ parentResourceId: freshBase.id,
+ }).then((res) => res.data);
+
+ expect(isEmpty(res)).toBe(true);
+
+ await permanentDeleteTable(freshBase.id, freshTable.id);
+ await permanentDeleteBase(freshBase.id);
+ });
+ });
});
diff --git a/apps/nextjs-app/src/backend/api/rest/ssr-api.ts b/apps/nextjs-app/src/backend/api/rest/ssr-api.ts
index 4c458ba8a9..d14cfcce1b 100644
--- a/apps/nextjs-app/src/backend/api/rest/ssr-api.ts
+++ b/apps/nextjs-app/src/backend/api/rest/ssr-api.ts
@@ -30,6 +30,9 @@ import type {
IUserLastVisitVo,
IUsageVo,
IUserLastVisitListBaseVo,
+ IUserLastVisitBaseNodeVo,
+ IGetUserLastVisitBaseNodeRo,
+ IBaseNodeListVo,
} from '@teable/openapi';
import {
ACCEPT_INVITATION_LINK,
@@ -63,6 +66,8 @@ import {
GET_USER_LAST_VISIT,
GET_INSTANCE_USAGE,
GET_USER_LAST_VISIT_LIST_BASE,
+ GET_USER_LAST_VISIT_BASE_NODE,
+ GET_BASE_NODE_LIST,
} from '@teable/openapi';
import type { AxiosInstance } from 'axios';
import { getAxios } from './axios';
@@ -267,6 +272,18 @@ export class SsrApi {
.then(({ data }) => data);
}
+ async getUserLastVisitBaseNode(params: IGetUserLastVisitBaseNodeRo) {
+ return this.axios
+ .get(GET_USER_LAST_VISIT_BASE_NODE, { params })
+ .then(({ data }) => data);
+ }
+
+ async getBaseNodeList(baseId: string) {
+ return this.axios
+ .get(urlBuilder(GET_BASE_NODE_LIST, { baseId }))
+ .then(({ data }) => data);
+ }
+
async getInstanceUsage() {
return this.axios.get(GET_INSTANCE_USAGE).then(({ data }) => data);
}
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-node/BaseNodeContext.ts b/apps/nextjs-app/src/features/app/blocks/base/base-node/BaseNodeContext.ts
new file mode 100644
index 0000000000..b4bdd6b3ec
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-node/BaseNodeContext.ts
@@ -0,0 +1,19 @@
+import { noop } from 'lodash';
+import { createContext } from 'react';
+import type { TreeItemData } from './hooks';
+
+export const BaseNodeContext = createContext<{
+ isLoading: boolean;
+ maxFolderDepth: number;
+ treeItems: Record;
+ setTreeItems: (
+ updater: (prev: Record) => Record
+ ) => void;
+ invalidateMenu: () => void;
+}>({
+ isLoading: false,
+ maxFolderDepth: 2,
+ treeItems: {},
+ setTreeItems: noop,
+ invalidateMenu: noop,
+});
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-node/BaseNodeProvider.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-node/BaseNodeProvider.tsx
new file mode 100644
index 0000000000..9b22837b78
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-node/BaseNodeProvider.tsx
@@ -0,0 +1,9 @@
+import { useBaseId } from '@teable/sdk/hooks';
+import { BaseNodeContext } from './BaseNodeContext';
+import { useBaseNode } from './hooks';
+
+export const BaseNodeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const baseId = useBaseId() as string;
+ const context = useBaseNode(baseId);
+ return {children};
+};
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/helper.ts b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/helper.ts
new file mode 100644
index 0000000000..816d7bfceb
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/helper.ts
@@ -0,0 +1,183 @@
+import type { UrlObject } from 'url';
+import { Table2 } from '@teable/icons';
+import type { IBaseNodeResourceMeta, IBaseNodeVo } from '@teable/openapi';
+import { BaseNodeResourceType, LastVisitResourceType } from '@teable/openapi';
+import { keyBy } from 'lodash';
+import { AppWindowMacIcon, BotIcon, CircleGaugeIcon, FolderClosedIcon } from 'lucide-react';
+import type { TreeItemData } from './useBaseNode';
+
+type TreeRootItem = {
+ id: typeof ROOT_ID;
+ resourceType: BaseNodeResourceType.Folder;
+ resourceId: typeof ROOT_ID;
+ resourceMeta: IBaseNodeResourceMeta;
+ children: string[];
+};
+
+export const ROOT_ID = '__root__';
+
+export const BaseNodeResourceIconMap = {
+ [BaseNodeResourceType.Folder]: FolderClosedIcon,
+ [BaseNodeResourceType.Dashboard]: CircleGaugeIcon,
+ [BaseNodeResourceType.Workflow]: BotIcon,
+ [BaseNodeResourceType.App]: AppWindowMacIcon,
+ [BaseNodeResourceType.Table]: Table2,
+};
+
+export const BaseNodeResourceLastVisitMap = {
+ [BaseNodeResourceType.Table]: LastVisitResourceType.Table,
+ [BaseNodeResourceType.Dashboard]: LastVisitResourceType.Dashboard,
+ [BaseNodeResourceType.Workflow]: LastVisitResourceType.Automation,
+ [BaseNodeResourceType.App]: LastVisitResourceType.App,
+};
+
+export const getNodeName = (node: { resourceMeta?: IBaseNodeResourceMeta }): string => {
+ return node.resourceMeta?.name ?? '';
+};
+
+export const getNodeIcon = (node: {
+ resourceMeta?: IBaseNodeResourceMeta;
+}): string | null | undefined => {
+ return node.resourceMeta?.icon;
+};
+
+export const getNodeUrl = (props: {
+ baseId: string;
+ resourceType: BaseNodeResourceType;
+ resourceId: string;
+ viewId?: string | null;
+}): UrlObject | null => {
+ const { baseId, resourceId, resourceType, viewId } = props;
+ switch (resourceType) {
+ case BaseNodeResourceType.Table:
+ if (viewId) {
+ return {
+ pathname: `/base/${baseId}/table/${resourceId}/${viewId}`,
+ };
+ }
+ return {
+ pathname: `/base/${baseId}/table/${resourceId}`,
+ };
+ case BaseNodeResourceType.Dashboard:
+ return {
+ pathname: `/base/${baseId}/dashboard/${resourceId}`,
+ };
+ case BaseNodeResourceType.Workflow:
+ return {
+ pathname: `/base/${baseId}/automation/${resourceId}`,
+ };
+ case BaseNodeResourceType.App:
+ return {
+ pathname: `/base/${baseId}/app/${resourceId}`,
+ };
+ case BaseNodeResourceType.Folder:
+ return null;
+ default:
+ return null;
+ }
+};
+
+export const parseNodeUrl = (props: {
+ baseId: string;
+ url: string;
+ urlParams: {
+ dashboardId?: string;
+ automationId?: string;
+ appId?: string;
+ tableId?: string;
+ };
+}) => {
+ const { baseId, url, urlParams } = props;
+ const { dashboardId, automationId, appId, tableId } = urlParams;
+ if (url.includes(`/base/${baseId}/dashboard/${dashboardId}`)) {
+ return {
+ resourceType: BaseNodeResourceType.Dashboard,
+ resourceId: dashboardId,
+ };
+ }
+ if (url.includes(`/base/${baseId}/automation/${automationId}`)) {
+ return {
+ resourceType: BaseNodeResourceType.Workflow,
+ resourceId: automationId,
+ };
+ }
+ if (url.includes(`/base/${baseId}/app/${appId}`)) {
+ return {
+ resourceType: BaseNodeResourceType.App,
+ resourceId: appId,
+ };
+ }
+ if (url.includes(`/base/${baseId}/table/${tableId}`)) {
+ return {
+ resourceType: BaseNodeResourceType.Table,
+ resourceId: tableId,
+ };
+ }
+ return null;
+};
+
+export const cleanParentId = (parentId?: string | null) => {
+ if (parentId === ROOT_ID) {
+ return null;
+ }
+ return parentId;
+};
+
+const cleanNodes = (nodes: IBaseNodeVo[], nodeMap: Record): IBaseNodeVo[] => {
+ return nodes.map((node) => {
+ let parentId = null;
+ if (node.parentId) {
+ const parentNode = nodeMap[node.parentId];
+ if (
+ parentNode?.id === node.parentId &&
+ parentNode.resourceType === BaseNodeResourceType.Folder
+ ) {
+ parentId = node.parentId;
+ } else {
+ console.error(
+ `base menu node ${node.id} parentId is not valid, node: ${JSON.stringify(node)}, parentNode: ${JSON.stringify(parentNode)}`
+ );
+ }
+ }
+ const originalChildren = node.children ?? [];
+ let children = originalChildren;
+ if (children) {
+ children = children.filter((child) => nodeMap[child.id]?.id === child.id);
+ if (children.length !== originalChildren.length) {
+ console.error('base menu node children is not valid', node);
+ }
+ }
+ return {
+ ...node,
+ parentId,
+ children,
+ };
+ });
+};
+
+export const buildTreeItems = (nodes: IBaseNodeVo[]): Record => {
+ const nodeMap = keyBy(nodes, 'id');
+ const cleanedNodes = cleanNodes(nodes, nodeMap);
+ const result: Record = {
+ [ROOT_ID]: {
+ id: ROOT_ID,
+ resourceType: BaseNodeResourceType.Folder,
+ resourceId: ROOT_ID,
+ resourceMeta: {
+ name: 'baseMenuRoot',
+ },
+ children: [],
+ },
+ };
+
+ for (const node of cleanedNodes) {
+ if (!node.parentId) {
+ result[ROOT_ID].children.push(node.id);
+ }
+ result[node.id] = {
+ ...node,
+ children: (node.children ?? []).map((child) => child.id),
+ };
+ }
+ return result as Record;
+};
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/index.ts b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/index.ts
new file mode 100644
index 0000000000..9f95a88acb
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useBaseNode';
+export * from './useBaseNodeCrud';
+export * from './helper';
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNode.ts b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNode.ts
new file mode 100644
index 0000000000..1d20e351d9
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNode.ts
@@ -0,0 +1,90 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { getBaseNodeChannel } from '@teable/core';
+import type { IBaseNodeVo } from '@teable/openapi';
+import { getBaseNodeTree } from '@teable/openapi';
+import { ReactQueryKeys } from '@teable/sdk/config';
+import { useConnection } from '@teable/sdk/hooks';
+import { isEmpty, get } from 'lodash';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { buildTreeItems } from './helper';
+
+export type TreeItemData = Omit & { children: string[] };
+
+export const useBaseNode = (baseId: string) => {
+ const { connection } = useConnection();
+ const channel = getBaseNodeChannel(baseId);
+ const presence = connection?.getPresence(channel);
+ const [nodes, setNodes] = useState([]);
+ const [treeItems, setTreeItems] = useState>({});
+
+ const queryClient = useQueryClient();
+ const { data: queryData, isLoading } = useQuery({
+ queryKey: ReactQueryKeys.baseNodeTree(baseId),
+ queryFn: ({ queryKey }) => getBaseNodeTree(queryKey[1]).then((res) => res.data),
+ enabled: Boolean(baseId),
+ });
+
+ const invalidateMenu = useCallback(() => {
+ if (baseId) {
+ queryClient.invalidateQueries({ queryKey: ReactQueryKeys.baseNodeTree(baseId) });
+ }
+ }, [baseId, queryClient]);
+
+ const maxFolderDepth = useMemo(() => {
+ return queryData?.maxFolderDepth ?? 2;
+ }, [queryData?.maxFolderDepth]);
+
+ useEffect(() => {
+ if (queryData?.nodes) {
+ setNodes(queryData?.nodes);
+ }
+ }, [queryData?.nodes, setNodes]);
+
+ useEffect(() => {
+ if (nodes.length > 0) {
+ setTreeItems(buildTreeItems(nodes));
+ } else {
+ setTreeItems({});
+ }
+ }, [nodes, setTreeItems]);
+
+ useEffect(() => {
+ if (!presence || !channel) {
+ return;
+ }
+
+ if (presence.subscribed) {
+ return;
+ }
+
+ presence.subscribe();
+
+ const receiveHandler = () => {
+ const { remotePresences } = presence;
+ if (!isEmpty(remotePresences)) {
+ const remotePayload = get(remotePresences, channel);
+ if (remotePayload) {
+ invalidateMenu();
+ }
+ }
+ };
+
+ presence.on('receive', receiveHandler);
+
+ return () => {
+ presence?.removeListener('receive', receiveHandler);
+ presence?.listenerCount('receive') === 0 && presence?.unsubscribe();
+ presence?.listenerCount('receive') === 0 && presence?.destroy();
+ };
+ }, [connection, presence, channel, setNodes, invalidateMenu]);
+
+ return useMemo(() => {
+ return {
+ isLoading,
+ maxFolderDepth,
+ treeItems,
+ setTreeItems,
+ invalidateMenu,
+ };
+ }, [isLoading, maxFolderDepth, treeItems, setTreeItems, invalidateMenu]);
+};
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNodeContext.ts b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNodeContext.ts
new file mode 100644
index 0000000000..151507d752
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNodeContext.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { BaseNodeContext } from '../BaseNodeContext';
+
+export const useBaseNodeContext = () => {
+ return useContext(BaseNodeContext);
+};
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNodeCrud.ts b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNodeCrud.ts
new file mode 100644
index 0000000000..0640378f5f
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-node/hooks/useBaseNodeCrud.ts
@@ -0,0 +1,106 @@
+import { useMutation } from '@tanstack/react-query';
+import { getUniqName } from '@teable/core';
+import type {
+ IMoveBaseNodeRo,
+ ICreateBaseNodeRo,
+ IDuplicateBaseNodeRo,
+ IUpdateBaseNodeRo,
+ IBaseNodeVo,
+} from '@teable/openapi';
+import {
+ moveBaseNode,
+ createBaseNode,
+ deleteBaseNode,
+ duplicateBaseNode,
+ updateBaseNode,
+ permanentDeleteBaseNode,
+} from '@teable/openapi';
+import { useBaseId } from '@teable/sdk/hooks';
+import { useTranslation } from 'next-i18next';
+import { useCallback, useMemo } from 'react';
+import { cleanParentId, getNodeName } from './helper';
+import { useBaseNodeContext } from './useBaseNodeContext';
+
+interface IUseBaseNodeCrudOptions {
+ onCreateSuccess?: (node: IBaseNodeVo) => void;
+ onDuplicateSuccess?: (node: IBaseNodeVo) => void;
+ onUpdateSuccess?: (node: IBaseNodeVo) => void;
+ onUpdateError?: (error: unknown, variables: { nodeId: string; ro: IUpdateBaseNodeRo }) => void;
+ onMoveSuccess?: (node: IBaseNodeVo) => void;
+ onDeleteSuccess?: (nodeId: string) => void;
+}
+
+export const useBaseNodeCrud = (props?: IUseBaseNodeCrudOptions) => {
+ const baseId = useBaseId() as string;
+ const { t } = useTranslation(['table', 'common']);
+
+ const { treeItems } = useBaseNodeContext();
+
+ const { mutateAsync: createNodeFn } = useMutation({
+ mutationFn: (ro: ICreateBaseNodeRo) => createBaseNode(baseId, ro).then((res) => res.data),
+ onSuccess: (node) => props?.onCreateSuccess?.(node),
+ });
+
+ const { mutateAsync: updateNodeFn } = useMutation({
+ mutationFn: ({ nodeId, ro }: { nodeId: string; ro: IUpdateBaseNodeRo }) =>
+ updateBaseNode(baseId, nodeId, ro).then((res) => res.data),
+ onSuccess: (node) => props?.onUpdateSuccess?.(node),
+ onError: (error, variables) => props?.onUpdateError?.(error, variables),
+ });
+
+ const { mutateAsync: duplicateNodeFn } = useMutation({
+ mutationFn: ({ nodeId, ro }: { nodeId: string; ro: IDuplicateBaseNodeRo }) =>
+ duplicateBaseNode(baseId, nodeId, ro).then((res) => res.data),
+ onSuccess: (node) => props?.onDuplicateSuccess?.(node),
+ });
+
+ const { mutateAsync: moveNodeFn } = useMutation({
+ mutationFn: ({ nodeId, ro }: { nodeId: string; ro: IMoveBaseNodeRo }) =>
+ moveBaseNode(baseId, nodeId, ro).then((res) => res.data),
+ onSuccess: (node) => props?.onMoveSuccess?.(node),
+ });
+
+ const { mutateAsync: deleteNodeFn } = useMutation({
+ mutationFn: ({ nodeId, permanent }: { nodeId: string; permanent?: boolean }) =>
+ permanent ? permanentDeleteBaseNode(baseId, nodeId) : deleteBaseNode(baseId, nodeId),
+ onSuccess: (_, { nodeId }) => props?.onDeleteSuccess?.(nodeId),
+ });
+
+ const createNode = useCallback(
+ async (params: ICreateBaseNodeRo) => {
+ const { name: rawName, parentId: rawParentId } = params;
+ const parentId = cleanParentId(rawParentId);
+ const name = rawName ?? t('common:untitled');
+ const nodes = Object.values(treeItems);
+ await createNodeFn({
+ ...params,
+ parentId,
+ name: getUniqName(
+ name,
+ nodes.map((node) => getNodeName(node))
+ ),
+ });
+ },
+ [createNodeFn, treeItems, t]
+ );
+
+ return useMemo(() => {
+ return {
+ createNode,
+ duplicateNode: async (nodeId: string, ro: IDuplicateBaseNodeRo) => {
+ return duplicateNodeFn({ nodeId, ro });
+ },
+ updateNode: async (nodeId: string, ro: IUpdateBaseNodeRo) => {
+ return updateNodeFn({ nodeId, ro });
+ },
+ deleteNode: async (nodeId: string, permanent?: boolean) => {
+ return deleteNodeFn({ nodeId, permanent });
+ },
+ moveNode: async (nodeId: string, ro: IMoveBaseNodeRo) => {
+ return moveNodeFn({ nodeId, ro });
+ },
+ };
+ }, [createNode, duplicateNodeFn, updateNodeFn, deleteNodeFn, moveNodeFn]);
+};
+
+export type BaseNodeCrudHooks = ReturnType;
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeAddResourceButton.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeAddResourceButton.tsx
new file mode 100644
index 0000000000..7edb6f9247
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeAddResourceButton.tsx
@@ -0,0 +1,194 @@
+import { getUniqName, ViewType } from '@teable/core';
+import { File, FileCsv, FileExcel } from '@teable/icons';
+import { BaseNodeResourceType, SUPPORTEDTYPE } from '@teable/openapi';
+import { useTables } from '@teable/sdk';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@teable/ui-lib';
+import { Button } from '@teable/ui-lib/shadcn/ui/button';
+import { useTranslation } from 'next-i18next';
+import { useState } from 'react';
+import { TableImport } from '../../import-table';
+import { useDefaultFields } from '../../table-list/useAddTable';
+import { BaseNodeResourceIconMap, ROOT_ID } from '../base-node/hooks';
+import type { BaseNodeCrudHooks } from '../base-node/hooks';
+
+interface BaseNodeAddResourceButtonProps {
+ parentId?: string;
+ canCreateFolder?: boolean;
+ canCreateTable?: boolean;
+ canCreateDashboard?: boolean;
+ canCreateWorkflow?: boolean;
+ canCreateApp?: boolean;
+ curdHooks: BaseNodeCrudHooks;
+ children: React.ReactNode;
+}
+
+export const BaseNodeAddResourceButton = (props: BaseNodeAddResourceButtonProps) => {
+ const {
+ curdHooks,
+ parentId,
+ canCreateFolder,
+ children,
+ canCreateTable,
+ canCreateDashboard,
+ canCreateWorkflow,
+ canCreateApp,
+ } = props;
+ const { t } = useTranslation(['table', 'common']);
+ const [tableImportdialogVisible, setTableImportdialogVisible] = useState(false);
+ const [fileType, setFileType] = useState(SUPPORTEDTYPE.CSV);
+ const importFile = (type: SUPPORTEDTYPE) => {
+ setTableImportdialogVisible(true);
+ setFileType(type);
+ };
+
+ const fieldRos = useDefaultFields();
+ const tables = useTables();
+
+ const AddTableMenuItems = () => {
+ if (!canCreateTable) return null;
+ return (
+ <>
+ {
+ curdHooks.createNode?.({
+ resourceType: BaseNodeResourceType.Table,
+ parentId,
+ fields: fieldRos,
+ views: [{ name: t('view.category.table'), type: ViewType.Grid }],
+ name: getUniqName(
+ t('table:table.newTableLabel'),
+ tables.map((table) => table.name)
+ ),
+ });
+ }}
+ className="cursor-pointer"
+ >
+
+
+ >
+ );
+ };
+
+ const AddResourceMenuItems = () => {
+ const list: Array<{
+ resourceType:
+ | BaseNodeResourceType.Workflow
+ | BaseNodeResourceType.App
+ | BaseNodeResourceType.Dashboard
+ | BaseNodeResourceType.Folder;
+ label: string;
+ }> = [];
+
+ if (canCreateWorkflow) {
+ list.push({
+ resourceType: BaseNodeResourceType.Workflow,
+ label: t('common:noun.automation'),
+ });
+ }
+ if (canCreateApp) {
+ list.push({
+ resourceType: BaseNodeResourceType.App,
+ label: t('common:noun.app'),
+ });
+ }
+ if (canCreateDashboard) {
+ list.push({
+ resourceType: BaseNodeResourceType.Dashboard,
+ label: t('common:noun.dashboard'),
+ });
+ }
+
+ if (canCreateFolder) {
+ list.push({
+ resourceType: BaseNodeResourceType.Folder,
+ label: t('common:noun.folder'),
+ });
+ }
+
+ if (list.length === 0) {
+ return null;
+ }
+
+ return list.map((item) => {
+ const { resourceType, label } = item;
+ const IconComponent = BaseNodeResourceIconMap[resourceType];
+ return (
+ {
+ curdHooks.createNode?.({
+ resourceType,
+ parentId,
+ name: label,
+ });
+ }}
+ >
+
+
+ );
+ });
+ };
+
+ const ImportTableMenuItems = () => {
+ if (!canCreateTable) return null;
+ if (parentId && parentId !== ROOT_ID) return null;
+ return (
+ <>
+
+
+ {t('table:import.menu.addFromOtherSource')}
+
+ importFile(SUPPORTEDTYPE.CSV)}>
+
+
+ importFile(SUPPORTEDTYPE.EXCEL)}
+ >
+
+
+ >
+ );
+ };
+
+ return (
+
+
+ {children}
+
+
+
+
+
+
+
+ {tableImportdialogVisible && (
+
setTableImportdialogVisible(open)}
+ />
+ )}
+
+ );
+};
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx
new file mode 100644
index 0000000000..77a4662f9c
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeMore.tsx
@@ -0,0 +1,447 @@
+/* eslint-disable sonarjs/no-identical-functions */
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { getUniqName } from '@teable/core';
+import {
+ Copy,
+ Export,
+ FileCsv,
+ FileExcel,
+ Import,
+ MoreHorizontal,
+ Pencil,
+ Settings,
+ Trash2,
+} from '@teable/icons';
+import type { IDuplicateBaseNodeRo } from '@teable/openapi';
+import { BaseNodeResourceType, SUPPORTEDTYPE } from '@teable/openapi';
+import { ReactQueryKeys } from '@teable/sdk/config';
+import { useBaseId, useBasePermission, useTables } from '@teable/sdk/hooks';
+import { ConfirmDialog } from '@teable/ui-lib/base';
+import {
+ Button,
+ DialogFooter,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+ Input,
+ Label,
+ Switch,
+} from '@teable/ui-lib/shadcn';
+import Link from 'next/link';
+import { useTranslation } from 'next-i18next';
+import { useMemo, useState } from 'react';
+import { useSetting } from '@/features/app/hooks/useSetting';
+import { tableConfig } from '@/features/i18n/table.config';
+import { useDownload } from '../../../hooks/useDownLoad';
+import { TableImport } from '../../import-table';
+
+interface IBaseNodeMoreProps {
+ resourceType: BaseNodeResourceType;
+ resourceId: string;
+
+ className?: string;
+
+ open?: boolean;
+ setOpen?: (open: boolean) => void;
+
+ onRename?: () => void;
+ onDelete?: (permanent: boolean, confirm?: boolean) => Promise;
+ onDuplicate?: (ro?: IDuplicateBaseNodeRo) => Promise;
+}
+
+interface ICommonOperationProps extends IBaseNodeMoreProps {
+ children?: React.ReactNode;
+ canRename?: boolean;
+ canDelete?: boolean;
+ canPermanentDelete?: boolean;
+ canDuplicate?: boolean;
+}
+
+const CommonOperation = (props: ICommonOperationProps) => {
+ const {
+ onRename,
+ onDuplicate,
+ onDelete,
+ children,
+ canRename = true,
+ canDelete = true,
+ canPermanentDelete = true,
+ canDuplicate = true,
+ className,
+ } = props;
+ const { t } = useTranslation(tableConfig.i18nNamespaces);
+
+ if (!canRename && !canDelete && !canDuplicate && !children) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+ {canRename && (
+ onRename?.()}>
+
+ {t('table:table.rename')}
+
+ )}
+ {children}
+ {canDuplicate && (
+ onDuplicate?.()}>
+
+ {t('table:import.menu.duplicate')}
+
+ )}
+ {canPermanentDelete && (
+ onDelete?.(true)}>
+
+ {t('common:actions.permanentDelete')}
+
+ )}
+ {canDelete && (
+ onDelete?.(false)}>
+
+ {t('common:actions.delete')}
+
+ )}
+
+
+ >
+ );
+};
+
+export const DashboardOperation = (props: IBaseNodeMoreProps) => {
+ const permission = useBasePermission();
+ const { disallowDashboard } = useSetting();
+ const canRename = Boolean(permission?.['base|update']);
+ const canDelete = false;
+ const canPermanentDelete = Boolean(permission?.['base|delete']);
+ const canDuplicate = Boolean(permission?.['base|update'] && !disallowDashboard);
+
+ return (
+
+ );
+};
+
+export const WorkflowOperation = (props: IBaseNodeMoreProps) => {
+ const permission = useBasePermission();
+ const canRename = Boolean(permission?.['automation|update']);
+ const canDelete = false;
+ const canPermanentDelete = Boolean(permission?.['automation|delete']);
+ const canDuplicate = Boolean(permission?.['automation|create']);
+
+ return (
+
+ );
+};
+
+export const AppOperation = (props: IBaseNodeMoreProps) => {
+ const permission = useBasePermission();
+ const canRename = Boolean(permission?.['base|update']);
+ const canDelete = false;
+ const canPermanentDelete = Boolean(permission?.['base|delete']);
+ const canDuplicate = false;
+
+ return (
+
+ );
+};
+
+export const FolderOperation = (props: IBaseNodeMoreProps) => {
+ const permission = useBasePermission();
+ const canRename = Boolean(permission?.['base|update']);
+ const canDelete = false;
+ const canPermanentDelete = Boolean(permission?.['base|delete']);
+ const canDuplicate = false;
+
+ return (
+
+ );
+};
+
+export const TableOperation = (props: IBaseNodeMoreProps) => {
+ const { resourceId, open, setOpen, onRename, className, onDelete, onDuplicate } = props;
+ const baseId = useBaseId() as string;
+ const tables = useTables();
+ const queryClient = useQueryClient();
+ const { t } = useTranslation(tableConfig.i18nNamespaces);
+ const permission = useBasePermission();
+
+ const [deleteConfirm, setDeleteConfirm] = useState(false);
+ const [importVisible, setImportVisible] = useState(false);
+ const [duplicateSetting, setDuplicateSetting] = useState(false);
+ const [importType, setImportType] = useState(SUPPORTEDTYPE.CSV);
+
+ const table = useMemo(() => tables.find((t) => t.id === resourceId), [tables, resourceId]);
+ const { trigger } = useDownload({ downloadUrl: `/api/export/${resourceId}`, key: 'table' });
+
+ const defaultTableName = useMemo(
+ () =>
+ getUniqName(
+ `${table?.name} ${t('space:baseModal.copy')}`,
+ tables.map((t) => t.name)
+ ),
+ [t, table?.name, tables]
+ );
+
+ const [duplicateOption, setDuplicateOption] = useState({
+ name: defaultTableName,
+ includeRecords: true,
+ });
+
+ const menuPermission = useMemo(() => {
+ return {
+ deleteTable: table?.permission?.['table|delete'],
+ updateTable: table?.permission?.['table|update'],
+ duplicateTable: table?.permission?.['table|read'] && permission?.['table|create'],
+ exportTable: table?.permission?.['table|export'],
+ importTable: table?.permission?.['table|import'],
+ };
+ }, [permission, table?.permission]);
+
+ const deleteTable = async (permanent: boolean) => {
+ if (!resourceId) return;
+ await onDelete?.(permanent, false);
+ setDeleteConfirm(false);
+ queryClient.invalidateQueries(ReactQueryKeys.getTrashItems(baseId as string));
+ };
+
+ const { mutateAsync: duplicateTableFn, isLoading } = useMutation({
+ mutationFn: async (ro?: IDuplicateBaseNodeRo) => onDuplicate?.(ro),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ReactQueryKeys.tableList(baseId as string),
+ });
+ setDuplicateSetting(false);
+ },
+ });
+
+ if (!table) {
+ return null;
+ }
+
+ if (!Object.values(menuPermission).some(Boolean)) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+ {menuPermission.updateTable && (
+ onRename?.()}>
+
+ {t('table:table.rename')}
+
+ )}
+
+
+
+ {t('common:noun.design')}
+
+
+ {menuPermission.duplicateTable && (
+ setDuplicateSetting(true)}>
+
+ {t('table:import.menu.duplicate')}
+
+ )}
+ {menuPermission.exportTable && (
+ trigger?.()}>
+
+ {t('table:import.menu.downAsCsv')}
+
+ )}
+ {menuPermission.importTable && (
+
+
+
+ {t('table:import.menu.importData')}
+
+
+
+ {
+ setImportVisible(true);
+ setImportType(SUPPORTEDTYPE.CSV);
+ }}
+ >
+
+ {t('table:import.menu.csvFile')}
+
+ {
+ setImportVisible(true);
+ setImportType(SUPPORTEDTYPE.EXCEL);
+ }}
+ >
+
+ {t('table:import.menu.excelFile')}
+
+
+
+
+ )}
+ {menuPermission.deleteTable && (
+ setDeleteConfirm(true)}>
+
+ {t('common:actions.delete')}
+
+ )}
+
+
+
+ {importVisible && (
+ setImportVisible(visible)}
+ />
+ )}
+
+ {deleteConfirm && (
+
+
+
{t('table:table.deleteTip1')}
+
{t('common:trash.description')}
+
+
+
+
+
+
+ >
+ }
+ />
+ )}
+
+ {duplicateSetting && (
+
+
+
+ {
+ const value = e.target.value;
+ setDuplicateOption((prev) => ({ ...prev, name: value }));
+ }}
+ />
+
+
+ {
+ setDuplicateOption((prev) => ({ ...prev, includeRecords: val }));
+ }}
+ />
+
+
+
+ }
+ onCancel={() => setDuplicateSetting(false)}
+ onConfirm={async () => {
+ await duplicateTableFn({
+ name: duplicateOption.name,
+ includeRecords: duplicateOption.includeRecords,
+ });
+ }}
+ />
+ )}
+ >
+ );
+};
+
+export const BaseNodeMore = (props: IBaseNodeMoreProps) => {
+ const { resourceType } = props;
+
+ switch (resourceType) {
+ case BaseNodeResourceType.Table:
+ return ;
+ case BaseNodeResourceType.Dashboard:
+ return ;
+ case BaseNodeResourceType.Workflow:
+ return ;
+ case BaseNodeResourceType.App:
+ return ;
+ case BaseNodeResourceType.Folder:
+ return ;
+ default:
+ return null;
+ }
+};
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeStarButton.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeStarButton.tsx
new file mode 100644
index 0000000000..a39c9507e6
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeStarButton.tsx
@@ -0,0 +1,32 @@
+import { BaseNodeResourceType, PinType } from '@teable/openapi';
+import { useMemo } from 'react';
+import { StarButton } from '../../space/space-side-bar/StarButton';
+
+interface IBaseNodeStarButtonProps {
+ resourceType: BaseNodeResourceType;
+ resourceId: string;
+}
+
+export const BaseNodeStarButton = (props: IBaseNodeStarButtonProps) => {
+ const { resourceType, resourceId } = props;
+ const pinType = useMemo(() => {
+ switch (resourceType) {
+ case BaseNodeResourceType.Table:
+ return PinType.Table;
+ case BaseNodeResourceType.Dashboard:
+ return PinType.Dashboard;
+ case BaseNodeResourceType.Workflow:
+ return PinType.Workflow;
+ case BaseNodeResourceType.App:
+ return PinType.App;
+ default:
+ return null;
+ }
+ }, [resourceType]);
+
+ if (!pinType) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx
new file mode 100644
index 0000000000..7ad0968cfa
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseNodeTree.tsx
@@ -0,0 +1,765 @@
+'use client';
+
+import { useMutation } from '@tanstack/react-query';
+import type {
+ IBaseNodeVo,
+ IDuplicateBaseNodeRo,
+ IBaseNodeWorkflowResourceMeta,
+ IUpdateUserLastVisitRo,
+} from '@teable/openapi';
+import {
+ BaseNodeResourceType,
+ updateUserLastVisit as updateUserLastVisitApi,
+} from '@teable/openapi';
+import { LocalStorageKeys } from '@teable/sdk/config';
+import { useBaseId, useBasePermission } from '@teable/sdk/hooks';
+import { useConfirm } from '@teable/ui-lib/base/dialog/confirm-modal';
+import {
+ AssistiveTreeDescription,
+ createOnDropHandler,
+ dragAndDropFeature,
+ hotkeysCoreFeature,
+ keyboardDragAndDropFeature,
+ selectionFeature,
+ syncDataLoaderFeature,
+ useTree,
+} from '@teable/ui-lib/base/headless-tree';
+import type { DragTarget, ItemInstance } from '@teable/ui-lib/base/headless-tree';
+import AddBoldIcon from '@teable/ui-lib/icons/app/add-bold.svg';
+import { Button, Input, Skeleton } from '@teable/ui-lib/shadcn';
+import { ScrollArea, ScrollBar } from '@teable/ui-lib/shadcn/ui/scroll-area';
+import { Tree, TreeDragLine, TreeItem, TreeItemLabel } from '@teable/ui-lib/src/shadcn/ui/tree';
+import { useParams } from 'next/navigation';
+import { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useClickAway, useLocalStorage } from 'react-use';
+import { Emoji } from '@/features/app/components/emoji/Emoji';
+import { EmojiPicker } from '@/features/app/components/emoji/EmojiPicker';
+import { useDisableAIAction } from '@/features/app/hooks/useDisableAIAction';
+import { useSetting } from '@/features/app/hooks/useSetting';
+import { useTableHref } from '../../table-list/useTableHref';
+import {
+ BaseNodeResourceIconMap,
+ BaseNodeResourceLastVisitMap,
+ getNodeIcon,
+ getNodeName,
+ getNodeUrl,
+ parseNodeUrl,
+ ROOT_ID,
+ useBaseNodeCrud,
+} from '../base-node/hooks';
+import type { TreeItemData } from '../base-node/hooks';
+import { useBaseNodeContext } from '../base-node/hooks/useBaseNodeContext';
+import { BaseNodeAddResourceButton } from './BaseNodeAddResourceButton';
+import { BaseNodeMore } from './BaseNodeMore';
+import { BaseNodeStarButton } from './BaseNodeStarButton';
+
+const INDENTATION_WIDTH = 16;
+const SCROLL_EDGE_THRESHOLD = 60; // pixels from edge to trigger scroll
+const SCROLL_MAX_SPEED = 15; // max pixels per frame
+
+// Custom hook for auto-scroll during drag
+const useDragAutoScroll = (viewportRef: React.RefObject) => {
+ const rafRef = useRef(null);
+
+ useEffect(() => {
+ const viewport = viewportRef.current;
+ if (!viewport) return;
+
+ let scrollSpeed = 0;
+
+ const scroll = () => {
+ if (scrollSpeed !== 0) {
+ viewport.scrollTop += scrollSpeed;
+ rafRef.current = requestAnimationFrame(scroll);
+ } else {
+ rafRef.current = null;
+ }
+ };
+
+ const handleDragOver = (e: DragEvent) => {
+ const rect = viewport.getBoundingClientRect();
+ const y = e.clientY;
+ const distanceFromTop = y - rect.top;
+ const distanceFromBottom = rect.bottom - y;
+
+ if (distanceFromTop < SCROLL_EDGE_THRESHOLD) {
+ // Accelerate based on proximity to edge
+ const ratio = 1 - distanceFromTop / SCROLL_EDGE_THRESHOLD;
+ scrollSpeed = -Math.round(SCROLL_MAX_SPEED * ratio);
+ if (!rafRef.current) rafRef.current = requestAnimationFrame(scroll);
+ } else if (distanceFromBottom < SCROLL_EDGE_THRESHOLD) {
+ const ratio = 1 - distanceFromBottom / SCROLL_EDGE_THRESHOLD;
+ scrollSpeed = Math.round(SCROLL_MAX_SPEED * ratio);
+ if (!rafRef.current) rafRef.current = requestAnimationFrame(scroll);
+ } else {
+ scrollSpeed = 0;
+ }
+ };
+
+ const stopScroll = () => {
+ scrollSpeed = 0;
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ };
+
+ viewport.addEventListener('dragover', handleDragOver);
+ viewport.addEventListener('dragend', stopScroll);
+ viewport.addEventListener('drop', stopScroll);
+
+ return () => {
+ viewport.removeEventListener('dragover', handleDragOver);
+ viewport.removeEventListener('dragend', stopScroll);
+ viewport.removeEventListener('drop', stopScroll);
+ stopScroll();
+ };
+ }, [viewportRef]);
+};
+
+type TreeMode = 'view' | 'edit';
+
+interface IBaseNodeTreeProps {
+ mode?: TreeMode;
+}
+
+export const BaseNodeTree = (props: IBaseNodeTreeProps) => {
+ const { mode = 'edit' } = props;
+ const isEditMode = mode === 'edit';
+ const { t } = useTranslation(['common']);
+ const baseId = useBaseId() as string;
+ const router = useRouter();
+ const urlPath = router.asPath;
+ const urlParams = useParams<{
+ dashboardId?: string;
+ automationId?: string;
+ appId?: string;
+ tableId?: string;
+ }>();
+ const tableHrefMap = useTableHref();
+ const permission = useBasePermission();
+ const { buildApp: buildAppEnabled } = useDisableAIAction();
+ const { disallowDashboard } = useSetting();
+ const canCreateTable = Boolean(permission?.['table|create']);
+ const canCreateDashboard = Boolean(permission?.['base|update'] && !disallowDashboard);
+ const canCreateWorkflow = Boolean(permission?.['automation|create']);
+ const canCreateApp = Boolean(buildAppEnabled && permission?.['base|update']);
+ const canCreateFolder = Boolean(permission?.['base|update']);
+
+ const canCreateResource =
+ isEditMode &&
+ Boolean(
+ canCreateTable || canCreateDashboard || canCreateWorkflow || canCreateApp || canCreateFolder
+ );
+ const canMoveNode = isEditMode && Boolean(permission?.['base|update']);
+
+ const { isLoading, maxFolderDepth, treeItems, setTreeItems, invalidateMenu } =
+ useBaseNodeContext();
+ const { confirm: comfirmModal } = useConfirm();
+ const [editingNodeId, setEditingNodeId] = useState(null);
+ const inputRef = useRef(null);
+ const draggedItemsRef = useRef[]>([]);
+ const treeItemsRef = useRef(treeItems);
+ const viewportRef = useRef(null);
+ const [selectedItems, setSelectedItems] = useState([]);
+ const [expandedItemsMap, setExpandedItemsMap] = useLocalStorage>(
+ LocalStorageKeys.BaseNodeTreeExpandedItems,
+ {}
+ );
+
+ const expandedItems = useMemo(() => expandedItemsMap?.[baseId] ?? [], [expandedItemsMap, baseId]);
+ const setExpandedItems = useCallback(
+ (updater: string[] | ((prev: string[]) => string[])) => {
+ setExpandedItemsMap((prev) => {
+ const currentExpanded = prev?.[baseId] ?? [];
+ const newExpanded = typeof updater === 'function' ? updater(currentExpanded) : updater;
+ return {
+ ...prev,
+ [baseId]: newExpanded,
+ };
+ });
+ },
+ [baseId, setExpandedItemsMap]
+ );
+
+ const { mutateAsync: updateUserLastVisit } = useMutation({
+ mutationFn: (ro: IUpdateUserLastVisitRo) => {
+ return updateUserLastVisitApi(ro);
+ },
+ });
+
+ const handlePrimaryAction = useCallback(
+ (item: ItemInstance) => {
+ const node = item.getItemData();
+ const { resourceType, resourceId } = node;
+ if (resourceType === BaseNodeResourceType.Table) {
+ const viewId = router.query.viewId as string;
+ const url = tableHrefMap[resourceId];
+ if (url) {
+ router.push({ pathname: url }, undefined, {
+ shallow: Boolean(viewId),
+ });
+ return;
+ }
+ }
+
+ const url = getNodeUrl({
+ baseId,
+ resourceType,
+ resourceId,
+ });
+ if (!url) return;
+ router.push(url, undefined, {
+ shallow: true,
+ });
+ },
+ [baseId, router, tableHrefMap]
+ );
+
+ const handleDrop = (items: ItemInstance[], target: DragTarget) => {
+ const handler = createOnDropHandler((parentItem, newChildrenIds) => {
+ setTreeItems((prevItems) => ({
+ ...prevItems,
+ [parentItem.getId()]: {
+ ...prevItems[parentItem.getId()],
+ children: newChildrenIds,
+ },
+ }));
+
+ if (draggedItemsRef.current.length > 0) {
+ const draggedItem = draggedItemsRef.current[0];
+ const draggedNodeId = draggedItem.getId();
+ const newIndex = newChildrenIds.indexOf(draggedNodeId);
+
+ if (newIndex !== -1) {
+ const parentId = parentItem.getId() === ROOT_ID ? null : parentItem.getId();
+ let anchorId: string | undefined;
+ let position: 'before' | 'after' | undefined;
+
+ if (newIndex > 0 && newChildrenIds[newIndex - 1]) {
+ anchorId = newChildrenIds[newIndex - 1];
+ position = 'after';
+ } else if (newChildrenIds[newIndex + 1]) {
+ anchorId = newChildrenIds[newIndex + 1];
+ position = 'before';
+ }
+ curdHooks.moveNode(draggedNodeId, {
+ parentId: anchorId ? undefined : parentId,
+ anchorId,
+ position,
+ });
+ }
+ }
+ });
+ if (!canMoveNode) return Promise.resolve();
+ draggedItemsRef.current = items;
+ return handler(items, target);
+ };
+
+ const tree = useTree({
+ state: {
+ selectedItems,
+ expandedItems,
+ },
+ setSelectedItems: (updater) => {
+ setSelectedItems((prev) => {
+ return typeof updater === 'function' ? updater(prev) : updater;
+ });
+ },
+ setExpandedItems,
+ rootItemId: ROOT_ID,
+ indent: INDENTATION_WIDTH,
+ dataLoader: {
+ getItem: (itemId) => treeItemsRef.current[itemId] ?? {},
+ getChildren: (itemId) => treeItemsRef.current[itemId]?.children ?? [],
+ },
+ getItemName: (item) => getNodeName(item.getItemData()),
+ isItemFolder: (item) => item.getItemData().resourceType === BaseNodeResourceType.Folder,
+ canReorder: true,
+ canDrop: (items, target) => {
+ // Basic validation
+ if (editingNodeId || !canMoveNode || items.length !== 1) return false;
+
+ const isDraggingFolder = items[0].isFolder();
+ const isReordering = 'childIndex' in target;
+
+ // === Non-folder items ===
+ if (!isDraggingFolder) {
+ // Reorder: ✅ allowed at any level
+ if (isReordering) return true;
+ // Drop into folder: ✅ | Drop into non-folder: ❌
+ return target.item.isFolder();
+ }
+
+ // === Folder items ===
+ if (isReordering) {
+ // Reorder at level 0, 1: ✅ | Reorder at level >= 2: ❌
+ return target.dragLineLevel < maxFolderDepth;
+ }
+
+ // Drop into level 0 folder: ✅ | Drop into level 1+ folder or non-folder: ❌
+ return target.item.isFolder() && getItemLevel(target.item) < maxFolderDepth - 1;
+ },
+ onDrop: handleDrop,
+ onPrimaryAction: handlePrimaryAction,
+ features: [
+ syncDataLoaderFeature,
+ selectionFeature,
+ hotkeysCoreFeature,
+ dragAndDropFeature,
+ keyboardDragAndDropFeature,
+ ],
+ });
+
+ const createSuccefulyCallback = useCallback(
+ (node: IBaseNodeVo) => {
+ const { resourceType, resourceId, parentId, resourceMeta } = node;
+ const viewId =
+ resourceType === BaseNodeResourceType.Table ? resourceMeta?.defaultViewId : undefined;
+ const parentItem = parentId ? treeItemsRef.current[parentId] : null;
+
+ const url = getNodeUrl({
+ baseId,
+ resourceType,
+ resourceId,
+ viewId,
+ });
+ if (url) {
+ if (resourceType === BaseNodeResourceType.Table) {
+ router.push(url, undefined, { shallow: Boolean(viewId) });
+ } else {
+ router.push(url, undefined, { shallow: true });
+ }
+ }
+
+ if (parentItem && parentItem.resourceType === BaseNodeResourceType.Folder) {
+ setExpandedItems((prev) => [...(prev ?? []), parentItem.id]);
+ }
+ invalidateMenu();
+ },
+ [baseId, router, invalidateMenu, setExpandedItems]
+ );
+
+ const duplicateSuccefulyCallback = useCallback(
+ (node: IBaseNodeVo) => {
+ const { resourceType, resourceId, resourceMeta } = node;
+ const viewId =
+ resourceType === BaseNodeResourceType.Table ? resourceMeta?.defaultViewId : undefined;
+ const url = getNodeUrl({
+ baseId,
+ resourceType,
+ resourceId,
+ viewId,
+ });
+ if (url) {
+ if (resourceType === BaseNodeResourceType.Table) {
+ router.push(url, undefined, { shallow: Boolean(viewId) });
+ } else {
+ router.push(url, undefined, { shallow: true });
+ }
+ }
+
+ invalidateMenu();
+ },
+ [baseId, router, invalidateMenu]
+ );
+
+ const getAllParentIds = useCallback((nodeId: string) => {
+ const parentIds: string[] = [];
+ let parentId = treeItemsRef.current[nodeId]?.parentId;
+ while (parentId) {
+ parentIds.push(parentId);
+ parentId = treeItemsRef.current[parentId]?.parentId;
+ }
+ return parentIds;
+ }, []);
+
+ const deleteSuccefulyCallback = useCallback(
+ (nodeId: string) => {
+ const clickNextItem = (nodeId: string) => {
+ if (!selectedItems.includes(nodeId)) {
+ return;
+ }
+ const item = tree.getItemInstance(nodeId);
+ if (!item) return;
+ if (item.isFolder()) {
+ return;
+ }
+ const allItems = tree.getItems();
+ const nonFolderItems = allItems.filter(
+ (item) => !item.isFolder() && item.getId() !== nodeId
+ );
+ if (nonFolderItems.length === 0) {
+ router.push(`/base/${baseId}`, undefined, { shallow: true });
+ return;
+ }
+
+ // Find next non-folder item by alternating below and above
+ const findNextNonFolderItem = (
+ startItem: ItemInstance
+ ): ItemInstance | null => {
+ let belowItem: ItemInstance | undefined = startItem;
+ let aboveItem: ItemInstance | undefined = startItem;
+
+ while (belowItem || aboveItem) {
+ // Try below first
+ if (belowItem) {
+ belowItem = belowItem.getItemBelow();
+ if (belowItem && !belowItem.isFolder()) {
+ return belowItem;
+ }
+ }
+ // Then try above
+ if (aboveItem) {
+ aboveItem = aboveItem.getItemAbove();
+ if (aboveItem && !aboveItem.isFolder()) {
+ return aboveItem;
+ }
+ }
+ }
+ return null;
+ };
+
+ const nextItem = findNextNonFolderItem(item);
+ if (nextItem) {
+ const nextParentIds = getAllParentIds(nextItem.getId());
+ setExpandedItems((prev) => [...new Set([...(prev ?? []), ...nextParentIds])]);
+ handlePrimaryAction(nextItem);
+ }
+ };
+ clickNextItem(nodeId);
+ invalidateMenu();
+ },
+ [
+ router,
+ baseId,
+ selectedItems,
+ tree,
+ invalidateMenu,
+ handlePrimaryAction,
+ setExpandedItems,
+ getAllParentIds,
+ ]
+ );
+
+ const curdHooks = useBaseNodeCrud({
+ onCreateSuccess: createSuccefulyCallback,
+ onDuplicateSuccess: duplicateSuccefulyCallback,
+ onDeleteSuccess: deleteSuccefulyCallback,
+ onMoveSuccess: () => invalidateMenu(),
+ onUpdateError: () => invalidateMenu(),
+ });
+
+ useEffect(() => {
+ treeItemsRef.current = treeItems;
+ }, [treeItems]);
+
+ useEffect(() => {
+ if (Object.keys(treeItems).length === 0) return;
+ const nodes = Object.values(treeItems);
+ const { resourceType, resourceId } = parseNodeUrl({ baseId, url: urlPath, urlParams }) ?? {};
+ const node = nodes.find(
+ (node) => node.resourceType === resourceType && node.resourceId === resourceId
+ );
+ if (!node) return;
+
+ setSelectedItems([node.id]);
+ const lastVisitResourceType =
+ BaseNodeResourceLastVisitMap[node.resourceType as keyof typeof BaseNodeResourceLastVisitMap];
+ if (lastVisitResourceType) {
+ updateUserLastVisit({
+ resourceId: node.resourceId,
+ resourceType: lastVisitResourceType,
+ parentResourceId: baseId,
+ });
+ }
+ }, [treeItems, urlPath, urlParams, baseId, updateUserLastVisit]);
+
+ useEffect(() => {
+ if (!Object.keys(treeItems).length) return;
+ tree.rebuildTree();
+ }, [tree, treeItems]);
+
+ useEffect(() => {
+ let timeout: NodeJS.Timeout | null = null;
+ if (editingNodeId) {
+ timeout = setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, 200);
+ }
+ return () => {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ };
+ }, [editingNodeId]);
+
+ useClickAway(inputRef, () => {
+ const update = (editingNodeId: string) => {
+ const item = tree.getItemInstance(editingNodeId);
+ if (!item) return;
+ const oldVal = item?.getItemName() ?? '';
+ const newVal = inputRef.current?.value ?? '';
+ if (oldVal === newVal) return;
+ const nodeId = item.getId();
+ setTreeItems((prevItems) => ({
+ ...prevItems,
+ [nodeId]: {
+ ...prevItems[nodeId],
+ resourceMeta: {
+ ...prevItems[nodeId].resourceMeta,
+ name: newVal,
+ },
+ },
+ }));
+ curdHooks.updateNode(nodeId, {
+ name: newVal,
+ });
+ };
+ if (editingNodeId) {
+ update(editingNodeId);
+ setEditingNodeId(null);
+ }
+ });
+
+ useDragAutoScroll(viewportRef);
+
+ if (!baseId) {
+ return null;
+ }
+
+ const ItemIcon = ({ item }: { item: ItemInstance }) => {
+ const nodeId = item.getId();
+ const data = item.getItemData();
+ if (!data) return null;
+ const IconComponent = BaseNodeResourceIconMap[data.resourceType];
+ const { resourceType } = data;
+ const icon = getNodeIcon(data);
+ const isFolder = item.isFolder();
+ if (isFolder) {
+ return ;
+ }
+ return (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
+ e.stopPropagation()}>
+ {resourceType === BaseNodeResourceType.Table && (
+ curdHooks.updateNode(nodeId, { icon })}
+ >
+ {icon ? (
+
+ ) : (
+
+ )}
+
+ )}
+ {resourceType !== BaseNodeResourceType.Table && (
+
+ )}
+
+ );
+ };
+
+ return (
+ <>
+ {isEditMode && (
+
+ )}
+
+ {isLoading && Object.keys(treeItems).length === 0 ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ {tree.getItems().map((item) => {
+ const nodeId = item.getId();
+ const node = item.getItemData();
+ if (!node || Object.keys(node).length === 0) return null;
+ const { resourceType, resourceId } = node;
+ const name = getNodeName(node);
+
+ return (
+
+
+
+
+ {editingNodeId === nodeId ? (
+
{
+ if (e.key === 'Enter') {
+ const newVal = e.currentTarget.value;
+ if (newVal && newVal !== item.getItemName()) {
+ curdHooks.updateNode(nodeId, { name: newVal });
+ }
+ setEditingNodeId(null);
+ } else if (e.key === 'Escape') {
+ setEditingNodeId(null);
+ }
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ }}
+ onMouseDown={(e) => {
+ e.stopPropagation();
+ }}
+ />
+ ) : (
+ <>
+
+
+ {item.getItemName()}
+ {node.resourceType === BaseNodeResourceType.Workflow &&
+ (node.resourceMeta as IBaseNodeWorkflowResourceMeta)?.isActive && (
+
+ )}
+
+ {
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
+
{
+ e.stopPropagation();
+ }}
+ className="flex shrink-0 cursor-pointer items-center gap-2"
+ >
+
+ {canCreateResource && (
+
+
+
+ )}
+
+
+
+ setEditingNodeId(nodeId)}
+ onDelete={async (
+ permanent: boolean,
+ confirm: boolean = true
+ ) => {
+ const titleMap = {
+ [BaseNodeResourceType.Folder]: t('common:noun.folder'),
+ [BaseNodeResourceType.Table]: t('common:noun.table'),
+ [BaseNodeResourceType.Dashboard]:
+ t('common:noun.dashboard'),
+ [BaseNodeResourceType.Workflow]:
+ t('common:noun.automation'),
+ [BaseNodeResourceType.App]: t('common:noun.app'),
+ };
+ const result = !confirm
+ ? true
+ : await comfirmModal({
+ title: `${t('common:actions.delete')} ${titleMap[resourceType] ?? ''}`,
+ description: t('common:actions.deleteTip', {
+ name,
+ }),
+ confirmText: t('common:actions.delete'),
+ cancelText: t('common:actions.cancel'),
+ confirmButtonVariant: 'destructive',
+ });
+ if (result) {
+ await curdHooks.deleteNode(nodeId, permanent);
+ }
+ }}
+ onDuplicate={async (ro?: IDuplicateBaseNodeRo) => {
+ await curdHooks.duplicateNode(nodeId, {
+ name,
+ ...(ro ?? {}),
+ });
+ }}
+ />
+
+
+ }
+ >
+ )}
+
+
+
+
+ );
+ })}
+
+
+
+
+ )}
+ >
+ );
+};
+
+const getItemLevel = (item: ItemInstance) => {
+ const meta = item.getItemMeta();
+ return meta.level;
+};
+
+const checkCanCreateFolder = (item: ItemInstance, maxFolderDepth: number) => {
+ const level = getItemLevel(item);
+ return level < maxFolderDepth - 1;
+};
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BasePageRouter.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BasePageRouter.tsx
new file mode 100644
index 0000000000..1ef7bf664d
--- /dev/null
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BasePageRouter.tsx
@@ -0,0 +1,142 @@
+import { Lock, MoreHorizontal, Settings, Trash2 } from '@teable/icons';
+import { BillingProductLevel } from '@teable/openapi';
+import { useBasePermission } from '@teable/sdk/hooks';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ cn,
+} from '@teable/ui-lib/shadcn';
+import { Button } from '@teable/ui-lib/shadcn/ui/button';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
+import { useMemo } from 'react';
+import { UpgradeWrapper } from '@/features/app/components/billing/UpgradeWrapper';
+import { tableConfig } from '@/features/i18n/table.config';
+import { QuickAction } from './QuickAction';
+
+const MoreMenu = () => {
+ const router = useRouter();
+ const { baseId } = router.query;
+ const { t } = useTranslation(tableConfig.i18nNamespaces);
+ const basePermission = useBasePermission();
+
+ return (
+
+
+
+
+
+ {basePermission?.['base|delete'] && (
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export const BasePageRouter = () => {
+ const router = useRouter();
+ const { baseId } = router.query;
+ const { t } = useTranslation(tableConfig.i18nNamespaces);
+ const basePermission = useBasePermission();
+
+ const pageRoutes: {
+ href: string;
+ label: string;
+ Icon: React.FC<{ className?: string }>;
+ billingLevel?: BillingProductLevel;
+ }[] = useMemo(
+ () =>
+ [
+ {
+ href: `/base/${baseId}/authority-matrix`,
+ label: t('common:noun.authorityMatrix'),
+ Icon: Lock,
+ hidden: !basePermission?.['base|authority_matrix_config'],
+ billingLevel: BillingProductLevel.Pro,
+ },
+ ].filter((item) => !item.hidden),
+ [baseId, basePermission, t]
+ );
+
+ return (
+ <>
+
+
+ {t('common:quickAction.title')}
+
+
+ {pageRoutes.map(({ href, label, Icon, billingLevel }) => {
+ return (
+
+ {({ badge }) => (
+ -
+
+
+ )}
+
+ );
+ })}
+
+
+
+ >
+ );
+};
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSideBar.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSideBar.tsx
index 932f54dc7f..546de1c643 100644
--- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSideBar.tsx
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/BaseSideBar.tsx
@@ -1,158 +1,13 @@
-import { Gauge, Lock, MoreHorizontal, Settings, Trash2 } from '@teable/icons';
-import { BillingProductLevel } from '@teable/openapi';
-import { useBasePermission } from '@teable/sdk/hooks';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
- cn,
-} from '@teable/ui-lib/shadcn';
-import { Button } from '@teable/ui-lib/shadcn/ui/button';
-import { AppWindowMac, Bot } from 'lucide-react';
-import Link from 'next/link';
-import { useRouter } from 'next/router';
-import { useTranslation } from 'next-i18next';
-import { useMemo } from 'react';
-import { UpgradeWrapper } from '@/features/app/components/billing/UpgradeWrapper';
-import { useDisableAIAction } from '@/features/app/hooks/useDisableAIAction';
-import { useIsEE } from '@/features/app/hooks/useIsEE';
-import { tableConfig } from '@/features/i18n/table.config';
-import { TableList } from '../../table-list/TableList';
-import { QuickAction } from './QuickAction';
+// import { TableList } from '../../table-list/TableList';
+import { BaseNodeTree } from './BaseNodeTree';
+import { BasePageRouter } from './BasePageRouter';
export const BaseSideBar = () => {
- const router = useRouter();
- const { baseId } = router.query;
- const { t } = useTranslation(tableConfig.i18nNamespaces);
- const basePermission = useBasePermission();
- const isEE = useIsEE();
- const { buildApp: buildAppEnabled } = useDisableAIAction();
-
- const pageRoutes: {
- href: string;
- label: string;
- Icon: React.FC<{ className?: string }>;
- billingLevel?: BillingProductLevel;
- }[] = useMemo(
- () =>
- [
- {
- href: `/base/${baseId}/app`,
- label: t('common:noun.app'),
- Icon: AppWindowMac,
- hidden: !basePermission?.['base|update'] || !buildAppEnabled,
- billingLevel: BillingProductLevel.Pro,
- },
- {
- href: `/base/${baseId}/dashboard`,
- label: t('common:noun.dashboard'),
- Icon: Gauge,
- hidden: !basePermission?.['base|read'],
- },
- {
- href: `/base/${baseId}/automation`,
- label: t('common:noun.automation'),
- Icon: Bot,
- hidden: !basePermission?.['automation|read'],
- billingLevel: isEE ? BillingProductLevel.Pro : undefined,
- },
- {
- href: `/base/${baseId}/authority-matrix`,
- label: t('common:noun.authorityMatrix'),
- Icon: Lock,
- hidden: !basePermission?.['base|authority_matrix_config'],
- billingLevel: BillingProductLevel.Pro,
- },
- ].filter((item) => !item.hidden),
- [baseId, basePermission, buildAppEnabled, isEE, t]
- );
-
return (
<>
-
-
- {t('common:quickAction.title')}
-
-
- {pageRoutes.map(({ href, label, Icon, billingLevel }) => {
- return (
-
- {({ badge }) => (
- -
-
-
- )}
-
- );
- })}
-
-
-
-
-
- {basePermission?.['base|delete'] && (
-
-
-
- )}
-
-
-
-
-
-
-
-
+
+ {/* */}
+
>
);
};
diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/QuickAction.tsx b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/QuickAction.tsx
index 576f0f0cac..d76830d438 100644
--- a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/QuickAction.tsx
+++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/QuickAction.tsx
@@ -1,7 +1,8 @@
import { LaptopIcon } from '@radix-ui/react-icons';
-import { Moon, Settings, Sun, Table2 } from '@teable/icons';
+import { Moon, Settings, Sun } from '@teable/icons';
import { useTheme } from '@teable/next-themes';
-import { useBase, useIsHydrated, useTables } from '@teable/sdk/hooks';
+import { BaseNodeResourceType } from '@teable/openapi';
+import { useBaseId, useIsHydrated } from '@teable/sdk/hooks';
import {
CommandDialog,
CommandInput,
@@ -13,18 +14,21 @@ import {
Button,
cn,
} from '@teable/ui-lib/shadcn';
+import { groupBy } from 'lodash';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
+import { Emoji } from '@/features/app/components/emoji/Emoji';
import { useSettingStore } from '@/features/app/components/setting/useSettingStore';
import { useModKeyStr } from '@/features/app/utils/get-mod-key-str';
import { tableConfig } from '@/features/i18n/table.config';
+import { BaseNodeResourceIconMap, getNodeIcon, getNodeName, getNodeUrl } from '../base-node/hooks';
+import { useBaseNodeContext } from '../base-node/hooks/useBaseNodeContext';
export const QuickAction = ({ children }: React.PropsWithChildren) => {
+ const baseId = useBaseId() as string;
const [open, setOpen] = useState(false);
- const tables = useTables();
- const base = useBase();
const setting = useSettingStore();
const router = useRouter();
const theme = useTheme();
@@ -42,6 +46,12 @@ export const QuickAction = ({ children }: React.PropsWithChildren) => {
const isHydrated = useIsHydrated();
+ const { treeItems } = useBaseNodeContext();
+ const baseNodeTypeItems = groupBy(
+ Object.values(treeItems).filter((item) => item.resourceType !== BaseNodeResourceType.Folder),
+ 'resourceType'
+ );
+
return (
<>
{!isDragging && (
- setIsEditing(true)}
- open={open}
- setOpen={setOpen}
- />
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions
+ e.stopPropagation()}>
+
+
setIsEditing(true)}
+ open={open}
+ setOpen={setOpen}
+ />
+
)}
diff --git a/apps/nextjs-app/src/features/app/blocks/table-list/TableOperation.tsx b/apps/nextjs-app/src/features/app/blocks/table-list/TableOperation.tsx
index 5c3ca271fa..5ef0d13c90 100644
--- a/apps/nextjs-app/src/features/app/blocks/table-list/TableOperation.tsx
+++ b/apps/nextjs-app/src/features/app/blocks/table-list/TableOperation.tsx
@@ -11,7 +11,7 @@ import {
FileExcel,
Copy,
} from '@teable/icons';
-import { duplicateTable, PinType, SUPPORTEDTYPE } from '@teable/openapi';
+import { duplicateTable, SUPPORTEDTYPE } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useBase, useBasePermission, useTables } from '@teable/sdk/hooks';
import type { Table } from '@teable/sdk/model';
@@ -38,7 +38,6 @@ import React, { useMemo, useState } from 'react';
import { tableConfig } from '@/features/i18n/table.config';
import { useDownload } from '../../hooks/useDownLoad';
import { TableImport } from '../import-table';
-import { StarButton } from '../space/space-side-bar/StarButton';
interface ITableOperationProps {
className?: string;
@@ -135,9 +134,7 @@ export const TableOperation = (props: ITableOperationProps) => {
}
return (
- // eslint-disable-next-line jsx-a11y/no-static-element-interactions
- e.stopPropagation()}>
-
+ <>
@@ -295,6 +292,6 @@ export const TableOperation = (props: ITableOperationProps) => {
duplicateTableFn();
}}
/>
-
+ >
);
};
diff --git a/apps/nextjs-app/src/features/app/blocks/table-list/useAddTable.ts b/apps/nextjs-app/src/features/app/blocks/table-list/useAddTable.ts
index 6f77d185d2..0b49f5da00 100644
--- a/apps/nextjs-app/src/features/app/blocks/table-list/useAddTable.ts
+++ b/apps/nextjs-app/src/features/app/blocks/table-list/useAddTable.ts
@@ -5,7 +5,7 @@ import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { useCallback } from 'react';
-const useDefaultFields = (): IFieldRo[] => {
+export const useDefaultFields = (): IFieldRo[] => {
const { t } = useTranslation('table');
return [
{ name: t('field.default.singleLineText.title'), type: FieldType.SingleLineText },
diff --git a/apps/nextjs-app/src/features/app/blocks/table-list/useTableHref.tsx b/apps/nextjs-app/src/features/app/blocks/table-list/useTableHref.tsx
index 87489e04a0..552e0453fc 100644
--- a/apps/nextjs-app/src/features/app/blocks/table-list/useTableHref.tsx
+++ b/apps/nextjs-app/src/features/app/blocks/table-list/useTableHref.tsx
@@ -20,7 +20,7 @@ export const useTableHref = () => {
const map: Record = {};
tables.forEach((table) => {
map[table.id] =
- `/base/${baseId}/${table.id}/${userLastVisitMap?.[table.id]?.resourceId || table.defaultViewId}`;
+ `/base/${baseId}/table/${table.id}/${userLastVisitMap?.[table.id]?.resourceId || table.defaultViewId}`;
});
return map;
}, [baseId, tables, userLastVisitMap]);
diff --git a/apps/nextjs-app/src/features/app/blocks/trash/BaseTrashPage.tsx b/apps/nextjs-app/src/features/app/blocks/trash/BaseTrashPage.tsx
index 73aa5e3c9a..7c6ee963da 100644
--- a/apps/nextjs-app/src/features/app/blocks/trash/BaseTrashPage.tsx
+++ b/apps/nextjs-app/src/features/app/blocks/trash/BaseTrashPage.tsx
@@ -59,6 +59,7 @@ export const BaseTrashPage = () => {
mutationFn: (props: { trashId: string }) => restoreTrash(props.trashId),
onSuccess: () => {
queryClient.invalidateQueries(ReactQueryKeys.getTrashItems(baseId));
+ queryClient.invalidateQueries(ReactQueryKeys.baseNodeTree(baseId));
toast.success(t('actions.restoreSucceed'));
},
});
diff --git a/apps/nextjs-app/src/features/app/blocks/view/hooks/useContextMenu.ts b/apps/nextjs-app/src/features/app/blocks/view/hooks/useContextMenu.ts
index 03434b8f2c..1233682a4d 100644
--- a/apps/nextjs-app/src/features/app/blocks/view/hooks/useContextMenu.ts
+++ b/apps/nextjs-app/src/features/app/blocks/view/hooks/useContextMenu.ts
@@ -17,7 +17,7 @@ export const useContextMenu = () => {
const copyRecordUrl = useCallback(
async (recordId: string) => {
if (!baseId || !tableId || !recordId) return;
- const recordUrl = `${publicOrigin}/base/${baseId}/${tableId}?recordId=${recordId}`;
+ const recordUrl = `${publicOrigin}/base/${baseId}/table/${tableId}?recordId=${recordId}`;
await syncCopy(recordUrl);
toast.success(t('sdk:expandRecord.copy'));
},
@@ -27,7 +27,7 @@ export const useContextMenu = () => {
const viewRecordHistory = useCallback(
async (recordId: string) => {
if (!baseId || !tableId || !recordId) return;
- const recordUrl = `${publicOrigin}/base/${baseId}/${tableId}?recordId=${recordId}&showHistory=true`;
+ const recordUrl = `${publicOrigin}/base/${baseId}/table/${tableId}?recordId=${recordId}&showHistory=true`;
await router.push(recordUrl, undefined, {
shallow: true,
});
@@ -38,7 +38,7 @@ export const useContextMenu = () => {
const addRecordComment = useCallback(
async (recordId: string) => {
if (!baseId || !tableId || !recordId) return;
- const recordUrl = `${publicOrigin}/base/${baseId}/${tableId}?recordId=${recordId}&showComment=true`;
+ const recordUrl = `${publicOrigin}/base/${baseId}/table/${tableId}?recordId=${recordId}&showComment=true`;
await router.push(recordUrl, undefined, {
shallow: true,
});
diff --git a/apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx b/apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx
index 851590b866..49059de6c5 100644
--- a/apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx
+++ b/apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx
@@ -10,7 +10,6 @@ import { ReactQueryKeys } from '@teable/sdk/config';
import { useBaseId, useBasePermission } from '@teable/sdk/hooks';
import {
Button,
- cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -22,12 +21,11 @@ import { toast } from '@teable/ui-lib/shadcn/ui/sonner';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
-import { useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { MenuDeleteItem } from '../components/MenuDeleteItem';
import { useBrand } from '../hooks/useBrand';
import { AddPluginDialog } from './components/AddPluginDialog';
-import { DashboardSwitcher } from './components/DashboardSwitcher';
export const DashboardHeader = (props: { dashboardId: string }) => {
const { dashboardId } = props;
@@ -35,7 +33,8 @@ export const DashboardHeader = (props: { dashboardId: string }) => {
const router = useRouter();
const queryClient = useQueryClient();
const [menuOpen, setMenuOpen] = useState(false);
- const [rename, setRename] = useState(null);
+ const [isRenaming, setIsRenaming] = useState(false);
+ const [editName, setEditName] = useState('');
const renameRef = useRef(null);
const { t } = useTranslation(dashboardConfig.i18nNamespaces);
const basePermissions = useBasePermission();
@@ -71,60 +70,77 @@ export const DashboardHeader = (props: { dashboardId: string }) => {
});
const { mutate: renameDashboardMutate } = useMutation({
- mutationFn: () => renameDashboard(baseId, dashboardId, rename!),
+ mutationFn: ({ name }: { name: string }) => renameDashboard(baseId, dashboardId, name),
onSuccess: () => {
- setRename(null);
+ setIsRenaming(false);
queryClient.invalidateQueries(ReactQueryKeys.getDashboardList(baseId));
},
});
const selectedDashboard = dashboardList?.find(({ id }) => id === dashboardId);
+ const dashboardName = selectedDashboard?.name ?? t('common:noun.dashboard');
+
+ const startRename = () => {
+ setIsRenaming(true);
+ setEditName(dashboardName);
+ };
+
+ const cancelRename = () => {
+ setIsRenaming(false);
+ setEditName(dashboardName);
+ };
+
const submitRename = () => {
- if (!rename || selectedDashboard?.name === rename) {
- setRename(null);
+ const newName = editName.trim();
+ if (dashboardName === newName) {
+ setIsRenaming(false);
return;
}
- renameDashboardMutate();
+ setIsRenaming(false);
+ renameDashboardMutate({ name: newName });
};
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ submitRename();
+ } else if (e.key === 'Escape') {
+ cancelRename();
+ }
+ };
+
+ useEffect(() => {
+ if (isRenaming && renameRef.current) {
+ renameRef.current.focus();
+ renameRef.current.select();
+ }
+ }, [isRenaming]);
+
return (
-
+
-
- {selectedDashboard?.name ? `${selectedDashboard?.name} - ${brandName}` : brandName}
-
+
{dashboardName ? `${dashboardName} - ${brandName}` : brandName}
-
{
- router.push({
- pathname: '/base/[baseId]/dashboard',
- query: { baseId, dashboardId },
- });
- }}
- />
- {
- submitRename();
- }}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- submitRename();
- }
- if (e.key === 'Escape') {
- setRename(null);
- }
- }}
- onChange={(e) => setRename(e.target.value)}
- />
+ {isRenaming ? (
+ setEditName(e.target.value)}
+ />
+ ) : (
+
+ )}
+
{canManage && (
@@ -142,12 +158,7 @@ export const DashboardHeader = (props: { dashboardId: string }) => {
- {
- setRename(selectedDashboard?.name ?? null);
- setTimeout(() => renameRef.current?.focus(), 200);
- }}
- >
+
{t('common:actions.rename')}
diff --git a/apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx b/apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx
index 40d8fa7594..25f6dc22e3 100644
--- a/apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx
+++ b/apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx
@@ -44,7 +44,7 @@ export const DashboardMain = (props: { dashboardId: string }) => {
);
}
return (
-
+
);
diff --git a/apps/nextjs-app/src/features/app/dashboard/Pages.tsx b/apps/nextjs-app/src/features/app/dashboard/Pages.tsx
index 7db45a8519..92f50c2380 100644
--- a/apps/nextjs-app/src/features/app/dashboard/Pages.tsx
+++ b/apps/nextjs-app/src/features/app/dashboard/Pages.tsx
@@ -1,11 +1,16 @@
import { useQuery } from '@tanstack/react-query';
+import { AlertCircle, X } from '@teable/icons';
import { getDashboardList, LastVisitResourceType, updateUserLastVisit } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import { useBaseId } from '@teable/sdk/hooks';
import { Spin } from '@teable/ui-lib/base';
+import { Button } from '@teable/ui-lib/shadcn';
import { useRouter } from 'next/router';
-import { useEffect } from 'react';
+import { useTranslation } from 'next-i18next';
+import { useEffect, useState } from 'react';
+import { dashboardConfig } from '@/features/i18n/dashboard.config';
import { useInitializationZodI18n } from '../hooks/useInitializationZodI18n';
+import { useSetting } from '../hooks/useSetting';
import { DashboardHeader } from './DashboardHeader';
import { DashboardMain } from './DashboardMain';
import { EmptyDashboard } from './EmptyDashboard';
@@ -13,6 +18,8 @@ import { EmptyDashboard } from './EmptyDashboard';
export function DashboardPage() {
const baseId = useBaseId()!;
const router = useRouter();
+ const { t } = useTranslation(dashboardConfig.i18nNamespaces);
+ const [showDeprecationBanner, setShowDeprecationBanner] = useState(true);
useInitializationZodI18n();
const dashboardQueryId = router.query.dashboardId as string | undefined;
const { data: dashboardList, isLoading } = useQuery({
@@ -20,7 +27,7 @@ export function DashboardPage() {
queryFn: ({ queryKey }) => getDashboardList(queryKey[1]).then((res) => res.data),
enabled: !!baseId,
});
-
+ const { disallowDashboard } = useSetting();
useEffect(() => {
if (dashboardQueryId) {
updateUserLastVisit({
@@ -46,6 +53,31 @@ export function DashboardPage() {
return (
+ {disallowDashboard && showDeprecationBanner && (
+
+
+
+
+
+ {t('dashboard:deprecation.title')}
+
+
+
+
+
+ {t('dashboard:deprecation.description')}
+
+
+
+
+ )}
);
diff --git a/apps/nextjs-app/src/features/app/dashboard/components/CreateDashboardDialog.tsx b/apps/nextjs-app/src/features/app/dashboard/components/CreateDashboardDialog.tsx
index 9a0c210a6f..c46c8a8fcf 100644
--- a/apps/nextjs-app/src/features/app/dashboard/components/CreateDashboardDialog.tsx
+++ b/apps/nextjs-app/src/features/app/dashboard/components/CreateDashboardDialog.tsx
@@ -55,7 +55,7 @@ export const CreateDashboardDialog = forwardRef<
setOpen(false);
setName('');
queryClient.invalidateQueries(ReactQueryKeys.getDashboardList(baseId));
- router.push(`/base/${baseId}/dashboard?dashboardId=${res.data.id}`);
+ router.push(`/base/${baseId}/dashboard/${res.data.id}`);
if (onSuccessCallback) {
onSuccessCallback?.(res.data.id);
}
diff --git a/apps/nextjs-app/src/features/app/hooks/useSetting.ts b/apps/nextjs-app/src/features/app/hooks/useSetting.ts
index f6599b0a26..5666718bf8 100644
--- a/apps/nextjs-app/src/features/app/hooks/useSetting.ts
+++ b/apps/nextjs-app/src/features/app/hooks/useSetting.ts
@@ -14,6 +14,7 @@ export const useSetting = () => {
disallowSignUp = false,
disallowSpaceCreation = false,
disallowSpaceInvitation = false,
+ disallowDashboard = false,
webSearchEnabled = false,
appGenerationEnabled = false,
createdTime,
@@ -23,6 +24,7 @@ export const useSetting = () => {
disallowSignUp,
disallowSpaceCreation: !user.isAdmin && (isLoading || disallowSpaceCreation),
disallowSpaceInvitation: !user.isAdmin && (isLoading || disallowSpaceInvitation),
+ disallowDashboard,
webSearchEnabled,
appGenerationEnabled,
createdTime,
diff --git a/apps/nextjs-app/src/features/app/layouts/BaseLayout.tsx b/apps/nextjs-app/src/features/app/layouts/BaseLayout.tsx
index 8601f9a6dd..b3e6f2a0c3 100644
--- a/apps/nextjs-app/src/features/app/layouts/BaseLayout.tsx
+++ b/apps/nextjs-app/src/features/app/layouts/BaseLayout.tsx
@@ -8,6 +8,7 @@ import { useTranslation } from 'next-i18next';
import React, { Fragment } from 'react';
import { AppLayout } from '@/features/app/layouts';
import { WorkFlowPanelModal } from '../automation/workflow-panel/WorkFlowPanelModal';
+import { BaseNodeProvider } from '../blocks/base/base-node/BaseNodeProvider';
import { BaseSideBar } from '../blocks/base/base-side-bar/BaseSideBar';
import { BaseSidebarHeaderLeft } from '../blocks/base/base-side-bar/BaseSidebarHeaderLeft';
import { BasePermissionListener } from '../blocks/base/BasePermissionListener';
@@ -40,29 +41,31 @@ export const BaseLayout: React.FC<{
}}
>
-
-
- e.preventDefault()}
- >
-
-
}>
-
-
-
-
-
-
-
-
-
{children}
+
+
+
+ e.preventDefault()}
+ >
+
+
}>
+
+
+
+
+
+
+
+
+
{children}
+
-
-
-
-
+
+
+
+
diff --git a/apps/nextjs-app/src/pages/base/[baseId].tsx b/apps/nextjs-app/src/pages/base/[baseId].tsx
index 78cbef2cca..0e5e653beb 100644
--- a/apps/nextjs-app/src/pages/base/[baseId].tsx
+++ b/apps/nextjs-app/src/pages/base/[baseId].tsx
@@ -1,9 +1,10 @@
import { dehydrate, QueryClient } from '@tanstack/react-query';
-import { LastVisitResourceType, type ITableVo } from '@teable/openapi';
+import { type ITableVo } from '@teable/openapi';
import { ReactQueryKeys } from '@teable/sdk/config';
import type { GetServerSideProps } from 'next';
import type { ReactElement } from 'react';
import { CommunityPage } from '@/features/app/base/CommunityPage';
+import { getNodeUrl } from '@/features/app/blocks/base/base-node/hooks/helper';
import { BaseLayout } from '@/features/app/layouts/BaseLayout';
import ensureLogin from '@/lib/ensureLogin';
import { getTranslationsProps } from '@/lib/i18n';
@@ -19,28 +20,27 @@ export const getServerSideProps: GetServerSideProps = withEnv(
ensureLogin(
withAuthSSR(async (context, ssrApi) => {
const { baseId } = context.query;
- const [userLastVisit, tables] = await Promise.all([
- ssrApi.getUserLastVisit(LastVisitResourceType.Table, baseId as string),
+ const [tables, userLastVisitNode, nodes] = await Promise.all([
ssrApi.getTables(baseId as string),
+ ssrApi.getUserLastVisitBaseNode({ parentResourceId: baseId as string }),
+ ssrApi.getBaseNodeList(baseId as string),
]);
- if (tables.length && userLastVisit && userLastVisit.childResourceId) {
- // if userLastVisit.resourceId has no permission to the tables, redirect to the first table
- if (tables.find((table) => table.id === userLastVisit.resourceId)) {
+ const findNode = nodes.find((node) => node.resourceId === userLastVisitNode?.resourceId);
+ if (findNode) {
+ const url = getNodeUrl({
+ baseId: baseId as string,
+ resourceType: findNode.resourceType,
+ resourceId: findNode.resourceId,
+ });
+ if (url && url.pathname) {
return {
redirect: {
- destination: `/base/${baseId}/${userLastVisit.resourceId}/${userLastVisit.childResourceId}`,
+ destination: url.pathname,
permanent: false,
},
};
}
-
- return {
- redirect: {
- destination: `/base/${baseId}/${tables[0].id}/${tables[0].defaultViewId}`,
- permanent: false,
- },
- };
}
const queryClient = new QueryClient();
diff --git a/apps/nextjs-app/src/pages/base/[baseId]/[tableId].tsx b/apps/nextjs-app/src/pages/base/[baseId]/[tableId].tsx
index 4d59f23edc..c410a08c24 100644
--- a/apps/nextjs-app/src/pages/base/[baseId]/[tableId].tsx
+++ b/apps/nextjs-app/src/pages/base/[baseId]/[tableId].tsx
@@ -8,14 +8,14 @@ const Node: NextPageWithLayout = () => {
};
export const getServerSideProps: GetServerSideProps = withAuthSSR(async (context, ssrApi) => {
- const { tableId, baseId, ...queryParams } = context.query;
+ const { baseId, tableId, ...queryParams } = context.query;
const queryString = new URLSearchParams(queryParams as Record
).toString();
- const userLastVisit = await ssrApi.getUserLastVisit(
+
+ const userLastVisitView = await ssrApi.getUserLastVisit(
LastVisitResourceType.View,
tableId as string
);
-
- if (!userLastVisit) {
+ if (!userLastVisitView) {
return {
notFound: true,
};
@@ -23,7 +23,7 @@ export const getServerSideProps: GetServerSideProps = withAuthSSR(async (context
return {
redirect: {
- destination: `/base/${baseId}/${tableId}/${userLastVisit.resourceId}?${queryString}`,
+ destination: `/base/${baseId}/table/${tableId}/${userLastVisitView.resourceId}?${queryString}`,
permanent: false,
},
};
diff --git a/apps/nextjs-app/src/pages/base/[baseId]/[tableId]/[viewId].tsx b/apps/nextjs-app/src/pages/base/[baseId]/[tableId]/[viewId].tsx
index 1a42d14c13..35d6eb22f9 100644
--- a/apps/nextjs-app/src/pages/base/[baseId]/[tableId]/[viewId].tsx
+++ b/apps/nextjs-app/src/pages/base/[baseId]/[tableId]/[viewId].tsx
@@ -1,15 +1,10 @@
-import { QueryClient, dehydrate } from '@tanstack/react-query';
-import { ReactQueryKeys } from '@teable/sdk';
import type { ReactElement } from 'react';
import type { ITableProps } from '@/features/app/blocks/table/Table';
import { Table } from '@/features/app/blocks/table/Table';
import { BaseLayout } from '@/features/app/layouts/BaseLayout';
-import { tableConfig } from '@/features/i18n/table.config';
import ensureLogin from '@/lib/ensureLogin';
-import { getTranslationsProps } from '@/lib/i18n';
import type { NextPageWithLayout } from '@/lib/type';
import type { IViewPageProps } from '@/lib/view-pages-data';
-import { getViewPageServerData } from '@/lib/view-pages-data';
import withAuthSSR from '@/lib/withAuthSSR';
import withEnv from '@/lib/withEnv';
import 'react-grid-layout/css/styles.css';
@@ -35,66 +30,15 @@ const Node: NextPageWithLayout = ({
export const getServerSideProps = withEnv(
ensureLogin(
- withAuthSSR(async (context, ssrApi) => {
- const { tableId, viewId, baseId, recordId, fromNotify: notifyId } = context.query;
- const queryClient = new QueryClient();
+ withAuthSSR(async (context) => {
+ const { baseId, tableId, viewId, ...queryParams } = context.query;
+ const queryString = new URLSearchParams(queryParams as Record).toString();
- await Promise.all([
- queryClient.fetchQuery({
- queryKey: ReactQueryKeys.base(baseId as string),
- queryFn: ({ queryKey }) =>
- queryKey[1] ? ssrApi.getBaseById(baseId as string) : undefined,
- }),
-
- queryClient.fetchQuery({
- queryKey: ReactQueryKeys.getBasePermission(baseId as string),
- queryFn: ({ queryKey }) => ssrApi.getBasePermission(queryKey[1]),
- }),
-
- queryClient.fetchQuery({
- queryKey: ReactQueryKeys.getTablePermission(baseId as string, tableId as string),
- queryFn: ({ queryKey }) => ssrApi.getTablePermission(queryKey[1], queryKey[2]),
- }),
- ]);
-
- let recordServerData;
- if (recordId) {
- if (notifyId) {
- await ssrApi.updateNotificationStatus(notifyId as string, { isRead: true });
- }
-
- recordServerData = await ssrApi.getRecord(tableId as string, recordId as string);
-
- if (!recordServerData) {
- return {
- redirect: {
- destination: `/base/${baseId}/${tableId}/${viewId}`,
- permanent: false,
- },
- };
- }
- }
-
- const serverData = await getViewPageServerData(
- ssrApi,
- baseId as string,
- tableId as string,
- viewId as string
- );
-
- if (serverData) {
- const { i18nNamespaces } = tableConfig;
- return {
- props: {
- ...serverData,
- ...(recordServerData ? { recordServerData } : {}),
- ...(await getTranslationsProps(context, i18nNamespaces)),
- dehydratedState: dehydrate(queryClient),
- },
- };
- }
return {
- notFound: true,
+ redirect: {
+ destination: `/base/${baseId}/table/${tableId}/${viewId}?${queryString}`,
+ permanent: false,
+ },
};
})
)
diff --git a/apps/nextjs-app/src/pages/base/[baseId]/dashboard/[dashboardId].tsx b/apps/nextjs-app/src/pages/base/[baseId]/dashboard/[dashboardId].tsx
new file mode 100644
index 0000000000..bbc0a59497
--- /dev/null
+++ b/apps/nextjs-app/src/pages/base/[baseId]/dashboard/[dashboardId].tsx
@@ -0,0 +1,58 @@
+import { dehydrate, QueryClient } from '@tanstack/react-query';
+import { type ITableVo } from '@teable/openapi';
+import { ReactQueryKeys } from '@teable/sdk/config';
+import type { GetServerSideProps } from 'next';
+import type { ReactElement } from 'react';
+import { DashboardPage } from '@/features/app/dashboard/Pages';
+import { BaseLayout } from '@/features/app/layouts/BaseLayout';
+import { dashboardConfig } from '@/features/i18n/dashboard.config';
+import ensureLogin from '@/lib/ensureLogin';
+import { getTranslationsProps } from '@/lib/i18n';
+import type { NextPageWithLayout } from '@/lib/type';
+import withAuthSSR from '@/lib/withAuthSSR';
+import 'react-grid-layout/css/styles.css';
+import 'react-resizable/css/styles.css';
+import withEnv from '@/lib/withEnv';
+
+const Node: NextPageWithLayout = () => ;
+
+export const getServerSideProps: GetServerSideProps = withEnv(
+ ensureLogin(
+ withAuthSSR(async (context, ssrApi) => {
+ const { baseId, dashboardId } = context.query;
+ const queryClient = new QueryClient();
+
+ const [tables] = await Promise.all([
+ ssrApi.getTables(baseId as string),
+
+ queryClient.fetchQuery({
+ queryKey: ReactQueryKeys.getBasePermission(baseId as string),
+ queryFn: ({ queryKey }) => ssrApi.getBasePermission(queryKey[1]),
+ }),
+ ]);
+
+ await queryClient.fetchQuery({
+ queryKey: ReactQueryKeys.getDashboard(dashboardId as string),
+ queryFn: ({ queryKey }) => ssrApi.getDashboard(baseId as string, queryKey[1]),
+ });
+
+ return {
+ props: {
+ tableServerData: tables,
+ dehydratedState: dehydrate(queryClient),
+ ...(await getTranslationsProps(context, dashboardConfig.i18nNamespaces)),
+ },
+ };
+ })
+ )
+);
+
+Node.getLayout = function getLayout(
+ page: ReactElement,
+ pageProps: {
+ tableServerData: ITableVo[];
+ }
+) {
+ return {page};
+};
+export default Node;
diff --git a/apps/nextjs-app/src/pages/base/[baseId]/dashboard.tsx b/apps/nextjs-app/src/pages/base/[baseId]/dashboard/index.tsx
similarity index 80%
rename from apps/nextjs-app/src/pages/base/[baseId]/dashboard.tsx
rename to apps/nextjs-app/src/pages/base/[baseId]/dashboard/index.tsx
index 9521a6ba81..9b1771c76f 100644
--- a/apps/nextjs-app/src/pages/base/[baseId]/dashboard.tsx
+++ b/apps/nextjs-app/src/pages/base/[baseId]/dashboard/index.tsx
@@ -19,7 +19,7 @@ const Node: NextPageWithLayout = () => ;
export const getServerSideProps: GetServerSideProps = withEnv(
ensureLogin(
withAuthSSR(async (context, ssrApi) => {
- const { baseId, dashboardId: dashboardIdQuery } = context.query;
+ const { baseId } = context.query;
const queryClient = new QueryClient();
const [tables, lastVisit, dashboardList] = await Promise.all([
@@ -38,24 +38,16 @@ export const getServerSideProps: GetServerSideProps = withEnv(
}),
]);
- if (!dashboardIdQuery && lastVisit) {
+ const dashboardId = lastVisit?.resourceId || dashboardList[0]?.id;
+ if (dashboardId) {
return {
redirect: {
- destination: `/base/${baseId}/dashboard?dashboardId=${lastVisit.resourceId}`,
+ destination: `/base/${baseId}/dashboard/${dashboardId}`,
permanent: false,
},
};
}
- const dashboardId = dashboardIdQuery ? (dashboardIdQuery as string) : dashboardList[0]?.id;
-
- if (dashboardId) {
- await queryClient.fetchQuery({
- queryKey: ReactQueryKeys.getDashboard(dashboardId),
- queryFn: ({ queryKey }) => ssrApi.getDashboard(baseId as string, queryKey[1]),
- });
- }
-
return {
props: {
tableServerData: tables,
diff --git a/apps/nextjs-app/src/pages/base/[baseId]/table/[tableId]/[viewId].tsx b/apps/nextjs-app/src/pages/base/[baseId]/table/[tableId]/[viewId].tsx
new file mode 100644
index 0000000000..7ad5f9f9ed
--- /dev/null
+++ b/apps/nextjs-app/src/pages/base/[baseId]/table/[tableId]/[viewId].tsx
@@ -0,0 +1,107 @@
+import { QueryClient, dehydrate } from '@tanstack/react-query';
+import { ReactQueryKeys } from '@teable/sdk';
+import type { ReactElement } from 'react';
+import type { ITableProps } from '@/features/app/blocks/table/Table';
+import { Table } from '@/features/app/blocks/table/Table';
+import { BaseLayout } from '@/features/app/layouts/BaseLayout';
+import { tableConfig } from '@/features/i18n/table.config';
+import ensureLogin from '@/lib/ensureLogin';
+import { getTranslationsProps } from '@/lib/i18n';
+import type { NextPageWithLayout } from '@/lib/type';
+import type { IViewPageProps } from '@/lib/view-pages-data';
+import { getViewPageServerData } from '@/lib/view-pages-data';
+import withAuthSSR from '@/lib/withAuthSSR';
+import withEnv from '@/lib/withEnv';
+import 'react-grid-layout/css/styles.css';
+import 'react-resizable/css/styles.css';
+
+const Node: NextPageWithLayout = ({
+ fieldServerData,
+ viewServerData,
+ recordsServerData,
+ recordServerData,
+ groupPointsServerDataMap,
+}) => {
+ return (
+
+ );
+};
+
+export const getServerSideProps = withEnv(
+ ensureLogin(
+ withAuthSSR(async (context, ssrApi) => {
+ const { tableId, viewId, baseId, recordId, fromNotify: notifyId } = context.query;
+ const queryClient = new QueryClient();
+
+ await Promise.all([
+ queryClient.fetchQuery({
+ queryKey: ReactQueryKeys.base(baseId as string),
+ queryFn: ({ queryKey }) =>
+ queryKey[1] ? ssrApi.getBaseById(baseId as string) : undefined,
+ }),
+
+ queryClient.fetchQuery({
+ queryKey: ReactQueryKeys.getBasePermission(baseId as string),
+ queryFn: ({ queryKey }) => ssrApi.getBasePermission(queryKey[1]),
+ }),
+
+ queryClient.fetchQuery({
+ queryKey: ReactQueryKeys.getTablePermission(baseId as string, tableId as string),
+ queryFn: ({ queryKey }) => ssrApi.getTablePermission(queryKey[1], queryKey[2]),
+ }),
+ ]);
+
+ let recordServerData;
+ if (recordId) {
+ if (notifyId) {
+ await ssrApi.updateNotificationStatus(notifyId as string, { isRead: true });
+ }
+
+ recordServerData = await ssrApi.getRecord(tableId as string, recordId as string);
+
+ if (!recordServerData) {
+ return {
+ redirect: {
+ destination: `/base/${baseId}/table/${tableId}/${viewId}`,
+ permanent: false,
+ },
+ };
+ }
+ }
+
+ const serverData = await getViewPageServerData(
+ ssrApi,
+ baseId as string,
+ tableId as string,
+ viewId as string
+ );
+
+ if (serverData) {
+ const { i18nNamespaces } = tableConfig;
+ return {
+ props: {
+ ...serverData,
+ ...(recordServerData ? { recordServerData } : {}),
+ ...(await getTranslationsProps(context, i18nNamespaces)),
+ dehydratedState: dehydrate(queryClient),
+ },
+ };
+ }
+ return {
+ notFound: true,
+ };
+ })
+ )
+);
+
+Node.getLayout = function getLayout(page: ReactElement, pageProps: IViewPageProps) {
+ return {page};
+};
+
+export default Node;
diff --git a/apps/nextjs-app/src/pages/base/[baseId]/table/[tableId]/index.tsx b/apps/nextjs-app/src/pages/base/[baseId]/table/[tableId]/index.tsx
new file mode 100644
index 0000000000..61c9e993b7
--- /dev/null
+++ b/apps/nextjs-app/src/pages/base/[baseId]/table/[tableId]/index.tsx
@@ -0,0 +1,33 @@
+import { LastVisitResourceType } from '@teable/openapi';
+import type { GetServerSideProps } from 'next';
+import type { NextPageWithLayout } from '@/lib/type';
+import withAuthSSR from '@/lib/withAuthSSR';
+
+const Node: NextPageWithLayout = () => {
+ return redirecting
;
+};
+
+export const getServerSideProps: GetServerSideProps = withAuthSSR(async (context, ssrApi) => {
+ const { baseId, tableId, ...queryParams } = context.query;
+ const queryString = new URLSearchParams(queryParams as Record).toString();
+
+ const userLastVisitView = await ssrApi.getUserLastVisit(
+ LastVisitResourceType.View,
+ tableId as string
+ );
+
+ if (!userLastVisitView) {
+ return {
+ notFound: true,
+ };
+ }
+
+ return {
+ redirect: {
+ destination: `/base/${baseId}/table/${tableId}/${userLastVisitView.resourceId}?${queryString}`,
+ permanent: false,
+ },
+ };
+});
+
+export default Node;
diff --git a/apps/nextjs-app/src/pages/base/[baseId]/table/index.tsx b/apps/nextjs-app/src/pages/base/[baseId]/table/index.tsx
new file mode 100644
index 0000000000..5d19813b83
--- /dev/null
+++ b/apps/nextjs-app/src/pages/base/[baseId]/table/index.tsx
@@ -0,0 +1,43 @@
+import { LastVisitResourceType } from '@teable/openapi';
+import type { GetServerSideProps } from 'next';
+import type { NextPageWithLayout } from '@/lib/type';
+import withAuthSSR from '@/lib/withAuthSSR';
+
+const Node: NextPageWithLayout = () => {
+ return redirecting
;
+};
+
+export const getServerSideProps: GetServerSideProps = withAuthSSR(async (context, ssrApi) => {
+ const { baseId, ...queryParams } = context.query;
+ const queryString = new URLSearchParams(queryParams as Record).toString();
+ const userLastVisitTable = await ssrApi.getUserLastVisit(
+ LastVisitResourceType.Table,
+ baseId as string
+ );
+
+ if (!userLastVisitTable) {
+ return {
+ notFound: true,
+ };
+ }
+
+ const userLastVisitView = await ssrApi.getUserLastVisit(
+ LastVisitResourceType.View,
+ userLastVisitTable.resourceId as string
+ );
+
+ if (!userLastVisitView) {
+ return {
+ notFound: true,
+ };
+ }
+
+ return {
+ redirect: {
+ destination: `/base/${baseId}/table/${userLastVisitTable.resourceId}/${userLastVisitView.resourceId}?${queryString}`,
+ permanent: false,
+ },
+ };
+});
+
+export default Node;
diff --git a/packages/common-i18n/src/locales/de/common.json b/packages/common-i18n/src/locales/de/common.json
index 580a0d7638..c350abd0a5 100644
--- a/packages/common-i18n/src/locales/de/common.json
+++ b/packages/common-i18n/src/locales/de/common.json
@@ -189,14 +189,24 @@
"dashboard": "Dashboard",
"automation": "Automatisierung",
"authorityMatrix": "Authority Matrix",
+ "design": "Design",
"adminPanel": "Admin Panel",
- "license": "Lizenz",
+ "license": "Selbst-gehostete Lizenz",
"instanceId": "Instanz ID",
"beta": "Beta",
"trash": "Papierkorb",
"global": "Global",
"organizationPanel": "Organization Panel",
- "unknownError": "Unbekannter Fehler"
+ "unknownError": "Unbekannter Fehler",
+ "pluginPanel": "Panel",
+ "pluginContextMenu": "Kontextmenü",
+ "plugin": "Plugins",
+ "copy": "Kopie",
+ "credits": "Credits",
+ "aiChat": "KI-Chat",
+ "app": "App",
+ "webSearch": "Websuche",
+ "folder": "Ordner"
},
"level": {
"free": "Free",
diff --git a/packages/common-i18n/src/locales/de/dashboard.json b/packages/common-i18n/src/locales/de/dashboard.json
index 4b168c8168..08a0d56d7d 100644
--- a/packages/common-i18n/src/locales/de/dashboard.json
+++ b/packages/common-i18n/src/locales/de/dashboard.json
@@ -11,5 +11,9 @@
"placeholder": "Name des Dashboards eingeben"
},
"findDashboard": "Ein Dashboards finden...",
- "expand": "Erweitern"
+ "expand": "Erweitern",
+ "deprecation": {
+ "title": "Die Dashboard-Knoten-Funktion wird eingestellt",
+ "description": "Um Ihnen ein intelligenteres und effizienteres Erlebnis zu bieten, werden wir die Unterstützung für die Dashboard-Knoten-Funktion einstellen. Sie können neue Dashboards einfach über KI in der KI-generierten Anwendung erstellen."
+ }
}
diff --git a/packages/common-i18n/src/locales/de/sdk.json b/packages/common-i18n/src/locales/de/sdk.json
index 0698d0f514..d0172e4aad 100644
--- a/packages/common-i18n/src/locales/de/sdk.json
+++ b/packages/common-i18n/src/locales/de/sdk.json
@@ -984,6 +984,24 @@
"baseAndSpaceMismatch": "Base {{baseId}} und Space {{spaceId}} stimmen nicht überein",
"templateNotFound": "Vorlage {{templateId}} nicht gefunden"
},
+ "baseNode": {
+ "invalidResourceType": "Ungültiger Ressourcentyp",
+ "notFound": "Basisknoten nicht gefunden",
+ "parentMustBeFolder": "Übergeordnetes Element muss ein Ordner sein",
+ "cannotDuplicateFolder": "Ordner kann nicht dupliziert werden",
+ "cannotDeleteEmptyFolder": "Ordner kann nicht gelöscht werden, da er nicht leer ist",
+ "onlyOneOfParentIdOrAnchorIdRequired": "Es darf nur parentId oder anchorId angegeben werden",
+ "cannotMoveToItself": "Knoten kann nicht zu sich selbst verschoben werden",
+ "cannotMoveToCircularReference": "Knoten kann nicht zu seinem eigenen Unterknoten verschoben werden (Zirkelverweis)",
+ "anchorIdOrParentIdRequired": "Mindestens parentId oder anchorId muss angegeben werden",
+ "parentNotFound": "Übergeordneter Knoten nicht gefunden",
+ "parentIsNotFolder": "Übergeordnetes Element ist kein Ordner",
+ "circularReference": "Zirkelverweis erkannt",
+ "folderDepthLimitExceeded": "Ordnertiefenlimit überschritten",
+ "folderNotFound": "Ordner nicht gefunden",
+ "anchorNotFound": "Ankerknoten nicht gefunden",
+ "nameAlreadyExists": "Name existiert bereits"
+ },
"dashboard": {
"notFound": "Dashboard nicht gefunden"
},
diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json
index 135ef7b700..a8cca0f0fe 100644
--- a/packages/common-i18n/src/locales/en/common.json
+++ b/packages/common-i18n/src/locales/en/common.json
@@ -282,7 +282,8 @@
"credits": "Credits",
"aiChat": "AI Chat",
"app": "App",
- "webSearch": "Web search"
+ "webSearch": "Web search",
+ "folder": "Folder"
},
"level": {
"free": "Free",
diff --git a/packages/common-i18n/src/locales/en/dashboard.json b/packages/common-i18n/src/locales/en/dashboard.json
index 35c75993de..e76f155a0a 100644
--- a/packages/common-i18n/src/locales/en/dashboard.json
+++ b/packages/common-i18n/src/locales/en/dashboard.json
@@ -10,5 +10,9 @@
"title": "Create new dashboard",
"placeholder": "Enter dashboard name"
},
- "findDashboard": "Find a dashboard..."
+ "findDashboard": "Find a dashboard...",
+ "deprecation": {
+ "title": "Dashboard node feature will be discontinued",
+ "description": "To bring you a smarter and more efficient experience, we will discontinue support for the dashboard node feature. You can easily create new dashboards through AI in the AI-generated application for a smoother experience."
+ }
}
diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json
index db7a5c2b1a..43f119bf3f 100644
--- a/packages/common-i18n/src/locales/en/sdk.json
+++ b/packages/common-i18n/src/locales/en/sdk.json
@@ -984,6 +984,24 @@
"baseAndSpaceMismatch": "Base {{baseId}} and space {{spaceId}} mismatch",
"templateNotFound": "Template {{templateId}} not found"
},
+ "baseNode": {
+ "invalidResourceType": "Invalid resource type",
+ "notFound": "Base node not found",
+ "parentMustBeFolder": "Parent must be a folder",
+ "cannotDuplicateFolder": "Cannot duplicate folder",
+ "cannotDeleteEmptyFolder": "Cannot delete folder because it is not empty",
+ "onlyOneOfParentIdOrAnchorIdRequired": "Only one of parentId or anchorId must be provided",
+ "cannotMoveToItself": "Cannot move node to itself",
+ "cannotMoveToCircularReference": "Cannot move node to its own child (circular reference)",
+ "anchorIdOrParentIdRequired": "At least one of parentId or anchorId must be provided",
+ "parentNotFound": "Parent node not found",
+ "parentIsNotFolder": "Parent is not a folder",
+ "circularReference": "Circular reference detected",
+ "folderDepthLimitExceeded": "Folder depth limit exceeded",
+ "folderNotFound": "Folder not found",
+ "anchorNotFound": "Anchor node not found",
+ "nameAlreadyExists": "Name already exists"
+ },
"dashboard": {
"notFound": "Dashboard not found"
},
diff --git a/packages/common-i18n/src/locales/es/common.json b/packages/common-i18n/src/locales/es/common.json
index 3706a3550a..465ad94623 100644
--- a/packages/common-i18n/src/locales/es/common.json
+++ b/packages/common-i18n/src/locales/es/common.json
@@ -181,15 +181,24 @@
"dashboard": "Panel",
"automation": "Automatización",
"authorityMatrix": "Matriz de Autoridad",
+ "design": "Diseño",
"adminPanel": "Panel de Administración",
- "license": "Licencia",
+ "license": "Licencia autoalojada",
"instanceId": "ID de Instancia",
"beta": "Beta",
"trash": "Papelera",
"global": "Global",
"organizationPanel": "Panel de Organización",
"unknownError": "Error desconocido",
- "float": "Flotar"
+ "pluginPanel": "Panel",
+ "pluginContextMenu": "Menú contextual",
+ "plugin": "Plugins",
+ "copy": "Copia",
+ "credits": "Créditos",
+ "aiChat": "Chat de IA",
+ "app": "Aplicación",
+ "webSearch": "Búsqueda web",
+ "folder": "Carpeta"
},
"level": {
"free": "Gratis",
diff --git a/packages/common-i18n/src/locales/es/dashboard.json b/packages/common-i18n/src/locales/es/dashboard.json
index f5f4cc8a72..683500178d 100644
--- a/packages/common-i18n/src/locales/es/dashboard.json
+++ b/packages/common-i18n/src/locales/es/dashboard.json
@@ -11,5 +11,9 @@
"placeholder": "Ingresa el nombre del tablero"
},
"findDashboard": "Buscar un tablero...",
- "expand": "Expandir"
+ "expand": "Expandir",
+ "deprecation": {
+ "title": "La función de nodo del tablero será descontinuada",
+ "description": "Para brindarte una experiencia más inteligente y eficiente, descontinuaremos el soporte para la función de nodo del tablero. Podrás crear nuevos tableros fácilmente a través de IA en la aplicación generada por IA."
+ }
}
diff --git a/packages/common-i18n/src/locales/es/sdk.json b/packages/common-i18n/src/locales/es/sdk.json
index c24a546ab4..ff45342bc6 100644
--- a/packages/common-i18n/src/locales/es/sdk.json
+++ b/packages/common-i18n/src/locales/es/sdk.json
@@ -996,6 +996,24 @@
"baseAndSpaceMismatch": "Base {{baseId}} y espacio {{spaceId}} no coinciden",
"templateNotFound": "Plantilla {{templateId}} no encontrada"
},
+ "baseNode": {
+ "invalidResourceType": "Tipo de recurso no válido",
+ "notFound": "Nodo base no encontrado",
+ "parentMustBeFolder": "El padre debe ser una carpeta",
+ "cannotDuplicateFolder": "No se puede duplicar la carpeta",
+ "cannotDeleteEmptyFolder": "No se puede eliminar la carpeta porque no está vacía",
+ "onlyOneOfParentIdOrAnchorIdRequired": "Solo se debe proporcionar parentId o anchorId",
+ "cannotMoveToItself": "No se puede mover el nodo a sí mismo",
+ "cannotMoveToCircularReference": "No se puede mover el nodo a su propio hijo (referencia circular)",
+ "anchorIdOrParentIdRequired": "Se debe proporcionar al menos parentId o anchorId",
+ "parentNotFound": "Nodo padre no encontrado",
+ "parentIsNotFolder": "El padre no es una carpeta",
+ "circularReference": "Referencia circular detectada",
+ "folderDepthLimitExceeded": "Límite de profundidad de carpeta excedido",
+ "folderNotFound": "Carpeta no encontrada",
+ "anchorNotFound": "Nodo ancla no encontrado",
+ "nameAlreadyExists": "El nombre ya existe"
+ },
"dashboard": {
"notFound": "Panel de control no encontrado"
},
diff --git a/packages/common-i18n/src/locales/fr/common.json b/packages/common-i18n/src/locales/fr/common.json
index 3f28522552..84f2e1630b 100644
--- a/packages/common-i18n/src/locales/fr/common.json
+++ b/packages/common-i18n/src/locales/fr/common.json
@@ -165,11 +165,24 @@
"dashboard": "Tableau de bord",
"automation": "Automatisation",
"authorityMatrix": "Matrice d'autorité",
+ "design": "Conception",
"adminPanel": "Panneau d'administration",
- "license": "Licence",
+ "license": "Licence auto-hébergée",
"instanceId": "ID d'instance",
"beta": "Bêta",
- "trash": "Corbeille"
+ "trash": "Corbeille",
+ "global": "Global",
+ "organizationPanel": "Panneau d'organisation",
+ "unknownError": "Erreur inconnue",
+ "pluginPanel": "Panneau",
+ "pluginContextMenu": "Menu contextuel",
+ "plugin": "Plugins",
+ "copy": "Copie",
+ "credits": "Crédits",
+ "aiChat": "Chat IA",
+ "app": "Application",
+ "webSearch": "Recherche web",
+ "folder": "Dossier"
},
"level": {
"free": "Gratuit",
diff --git a/packages/common-i18n/src/locales/fr/sdk.json b/packages/common-i18n/src/locales/fr/sdk.json
index efb3138ae1..1beda33f0b 100644
--- a/packages/common-i18n/src/locales/fr/sdk.json
+++ b/packages/common-i18n/src/locales/fr/sdk.json
@@ -984,6 +984,24 @@
"baseAndSpaceMismatch": "La base {{baseId}} et l'espace {{spaceId}} ne correspondent pas",
"templateNotFound": "Modèle {{templateId}} non trouvé"
},
+ "baseNode": {
+ "invalidResourceType": "Type de ressource invalide",
+ "notFound": "Nœud de base non trouvé",
+ "parentMustBeFolder": "Le parent doit être un dossier",
+ "cannotDuplicateFolder": "Impossible de dupliquer le dossier",
+ "cannotDeleteEmptyFolder": "Impossible de supprimer le dossier car il n'est pas vide",
+ "onlyOneOfParentIdOrAnchorIdRequired": "Seul parentId ou anchorId doit être fourni",
+ "cannotMoveToItself": "Impossible de déplacer le nœud vers lui-même",
+ "cannotMoveToCircularReference": "Impossible de déplacer le nœud vers son propre enfant (référence circulaire)",
+ "anchorIdOrParentIdRequired": "Au moins parentId ou anchorId doit être fourni",
+ "parentNotFound": "Nœud parent non trouvé",
+ "parentIsNotFolder": "Le parent n'est pas un dossier",
+ "circularReference": "Référence circulaire détectée",
+ "folderDepthLimitExceeded": "Limite de profondeur de dossier dépassée",
+ "folderNotFound": "Dossier non trouvé",
+ "anchorNotFound": "Nœud d'ancrage non trouvé",
+ "nameAlreadyExists": "Le nom existe déjà"
+ },
"dashboard": {
"notFound": "Tableau de bord non trouvé"
},
diff --git a/packages/common-i18n/src/locales/it/common.json b/packages/common-i18n/src/locales/it/common.json
index 1ccdebc879..5c735b8e47 100644
--- a/packages/common-i18n/src/locales/it/common.json
+++ b/packages/common-i18n/src/locales/it/common.json
@@ -185,15 +185,24 @@
"dashboard": "Dashboard",
"automation": "Automazione",
"authorityMatrix": "Matrice di autorità",
+ "design": "Design",
"adminPanel": "Pannello di amministrazione",
- "license": "Licenza",
+ "license": "Licenza self-hosted",
"instanceId": "ID istanza",
"beta": "Beta",
"trash": "Cestino",
"global": "Globale",
"organizationPanel": "Pannello dell'organizzazione",
"unknownError": "Errore sconosciuto",
- "float": "Virgola mobile"
+ "pluginPanel": "Pannello",
+ "pluginContextMenu": "Menu contestuale",
+ "plugin": "Plugin",
+ "copy": "Copia",
+ "credits": "Crediti",
+ "aiChat": "Chat AI",
+ "app": "App",
+ "webSearch": "Ricerca web",
+ "folder": "Cartella"
},
"level": {
"free": "Gratuito",
diff --git a/packages/common-i18n/src/locales/it/dashboard.json b/packages/common-i18n/src/locales/it/dashboard.json
index 560f7c607c..0c2cd93335 100644
--- a/packages/common-i18n/src/locales/it/dashboard.json
+++ b/packages/common-i18n/src/locales/it/dashboard.json
@@ -11,5 +11,9 @@
"placeholder": "Inserisci il nome della dashboard"
},
"findDashboard": "Trova una dashboard...",
- "expand": "Espandi"
+ "expand": "Espandi",
+ "deprecation": {
+ "title": "La funzione del nodo dashboard sarà interrotta",
+ "description": "Per offrirti un'esperienza più intelligente ed efficiente, interromperemo il supporto per la funzione del nodo dashboard. Potrai creare facilmente nuove dashboard tramite AI nell'applicazione generata da AI."
+ }
}
diff --git a/packages/common-i18n/src/locales/it/sdk.json b/packages/common-i18n/src/locales/it/sdk.json
index 81ea83f1e0..78847059b5 100644
--- a/packages/common-i18n/src/locales/it/sdk.json
+++ b/packages/common-i18n/src/locales/it/sdk.json
@@ -984,6 +984,24 @@
"baseAndSpaceMismatch": "Base {{baseId}} e spazio {{spaceId}} non corrispondono",
"templateNotFound": "Modello {{templateId}} non trovato"
},
+ "baseNode": {
+ "invalidResourceType": "Tipo di risorsa non valido",
+ "notFound": "Nodo base non trovato",
+ "parentMustBeFolder": "Il genitore deve essere una cartella",
+ "cannotDuplicateFolder": "Impossibile duplicare la cartella",
+ "cannotDeleteEmptyFolder": "Impossibile eliminare la cartella perché non è vuota",
+ "onlyOneOfParentIdOrAnchorIdRequired": "È necessario fornire solo parentId o anchorId",
+ "cannotMoveToItself": "Impossibile spostare il nodo su se stesso",
+ "cannotMoveToCircularReference": "Impossibile spostare il nodo sul proprio figlio (riferimento circolare)",
+ "anchorIdOrParentIdRequired": "È necessario fornire almeno parentId o anchorId",
+ "parentNotFound": "Nodo genitore non trovato",
+ "parentIsNotFolder": "Il genitore non è una cartella",
+ "circularReference": "Riferimento circolare rilevato",
+ "folderDepthLimitExceeded": "Limite di profondità della cartella superato",
+ "folderNotFound": "Cartella non trovata",
+ "anchorNotFound": "Nodo di ancoraggio non trovato",
+ "nameAlreadyExists": "Il nome esiste già"
+ },
"dashboard": {
"notFound": "Dashboard non trovato"
},
diff --git a/packages/common-i18n/src/locales/ja/common.json b/packages/common-i18n/src/locales/ja/common.json
index a85e2d0ac8..c417bd2a05 100644
--- a/packages/common-i18n/src/locales/ja/common.json
+++ b/packages/common-i18n/src/locales/ja/common.json
@@ -160,14 +160,28 @@
"space": "スペース",
"base": "ベース",
"field": "フィールド",
- "record": "コード",
+ "record": "レコード",
"dashboard": "ダッシュボード",
"automation": "オートメーション",
"authorityMatrix": "権限マトリクス",
+ "design": "デザイン",
"adminPanel": "管理パネル",
- "license": "ライセンス",
- "instanceId": "ライセンスID",
- "beta": "ベータ"
+ "license": "セルフホストライセンス",
+ "instanceId": "インスタンスID",
+ "beta": "ベータ",
+ "trash": "ゴミ箱",
+ "global": "グローバル",
+ "organizationPanel": "組織パネル",
+ "unknownError": "不明なエラー",
+ "pluginPanel": "パネル",
+ "pluginContextMenu": "コンテキストメニュー",
+ "plugin": "プラグイン",
+ "copy": "コピー",
+ "credits": "クレジット",
+ "aiChat": "AIチャット",
+ "app": "アプリ",
+ "webSearch": "ウェブ検索",
+ "folder": "フォルダ"
},
"level": {
"free": "無料",
diff --git a/packages/common-i18n/src/locales/ja/sdk.json b/packages/common-i18n/src/locales/ja/sdk.json
index f478ec9b11..eea0195569 100644
--- a/packages/common-i18n/src/locales/ja/sdk.json
+++ b/packages/common-i18n/src/locales/ja/sdk.json
@@ -984,6 +984,24 @@
"baseAndSpaceMismatch": "ベース {{baseId}} とスペース {{spaceId}} が一致しません",
"templateNotFound": "テンプレート {{templateId}} が見つかりません"
},
+ "baseNode": {
+ "invalidResourceType": "無効なリソースタイプ",
+ "notFound": "ベースノードが見つかりません",
+ "parentMustBeFolder": "親はフォルダーである必要があります",
+ "cannotDuplicateFolder": "フォルダーを複製できません",
+ "cannotDeleteEmptyFolder": "フォルダーが空ではないため削除できません",
+ "onlyOneOfParentIdOrAnchorIdRequired": "parentId または anchorId のいずれか一方のみを指定してください",
+ "cannotMoveToItself": "ノードを自分自身に移動できません",
+ "cannotMoveToCircularReference": "ノードを自身の子ノードに移動できません(循環参照)",
+ "anchorIdOrParentIdRequired": "parentId または anchorId のいずれかを指定する必要があります",
+ "parentNotFound": "親ノードが見つかりません",
+ "parentIsNotFolder": "親がフォルダーではありません",
+ "circularReference": "循環参照が検出されました",
+ "folderDepthLimitExceeded": "フォルダーの深さ制限を超えました",
+ "folderNotFound": "フォルダーが見つかりません",
+ "anchorNotFound": "アンカーノードが見つかりません",
+ "nameAlreadyExists": "名前がすでに存在します"
+ },
"dashboard": {
"notFound": "ダッシュボードが見つかりません"
},
diff --git a/packages/common-i18n/src/locales/ru/common.json b/packages/common-i18n/src/locales/ru/common.json
index ada60affec..9287d25bac 100644
--- a/packages/common-i18n/src/locales/ru/common.json
+++ b/packages/common-i18n/src/locales/ru/common.json
@@ -165,11 +165,24 @@
"dashboard": "Панель",
"automation": "Автоматизация",
"authorityMatrix": "Матрица полномочий",
+ "design": "Дизайн",
"adminPanel": "Административная панель",
- "license": "Лицензия",
+ "license": "Самостоятельная лицензия",
"instanceId": "ID экземпляра",
"beta": "Бета",
- "trash": "Корзина"
+ "trash": "Корзина",
+ "global": "Глобальный",
+ "organizationPanel": "Панель организации",
+ "unknownError": "Неизвестная ошибка",
+ "pluginPanel": "Панель",
+ "pluginContextMenu": "Контекстное меню",
+ "plugin": "Плагины",
+ "copy": "Копия",
+ "credits": "Кредиты",
+ "aiChat": "ИИ-чат",
+ "app": "Приложение",
+ "webSearch": "Веб-поиск",
+ "folder": "Папка"
},
"level": {
"free": "Бесплатно",
diff --git a/packages/common-i18n/src/locales/ru/dashboard.json b/packages/common-i18n/src/locales/ru/dashboard.json
index 981fc6b32b..b278a893bd 100644
--- a/packages/common-i18n/src/locales/ru/dashboard.json
+++ b/packages/common-i18n/src/locales/ru/dashboard.json
@@ -19,5 +19,9 @@
"pluginNotFound": "Плагин не найден",
"pluginEmpty": {
"title": "Пока нет плагинов"
+ },
+ "deprecation": {
+ "title": "Функция узла дашборда будет прекращена",
+ "description": "Чтобы обеспечить вам более умный и эффективный опыт, мы прекратим поддержку функции узла дашборда. Вы сможете легко создавать новые дашборды с помощью ИИ в приложении, созданном ИИ."
}
}
diff --git a/packages/common-i18n/src/locales/ru/sdk.json b/packages/common-i18n/src/locales/ru/sdk.json
index 70d5447452..042fbd8a07 100644
--- a/packages/common-i18n/src/locales/ru/sdk.json
+++ b/packages/common-i18n/src/locales/ru/sdk.json
@@ -984,6 +984,24 @@
"baseAndSpaceMismatch": "База {{baseId}} и пространство {{spaceId}} не совпадают",
"templateNotFound": "Шаблон {{templateId}} не найден"
},
+ "baseNode": {
+ "invalidResourceType": "Недопустимый тип ресурса",
+ "notFound": "Базовый узел не найден",
+ "parentMustBeFolder": "Родитель должен быть папкой",
+ "cannotDuplicateFolder": "Невозможно дублировать папку",
+ "cannotDeleteEmptyFolder": "Невозможно удалить папку, так как она не пуста",
+ "onlyOneOfParentIdOrAnchorIdRequired": "Необходимо указать только parentId или anchorId",
+ "cannotMoveToItself": "Невозможно переместить узел на себя",
+ "cannotMoveToCircularReference": "Невозможно переместить узел в свой собственный дочерний узел (циклическая ссылка)",
+ "anchorIdOrParentIdRequired": "Необходимо указать хотя бы parentId или anchorId",
+ "parentNotFound": "Родительский узел не найден",
+ "parentIsNotFolder": "Родитель не является папкой",
+ "circularReference": "Обнаружена циклическая ссылка",
+ "folderDepthLimitExceeded": "Превышен лимит глубины папки",
+ "folderNotFound": "Папка не найдена",
+ "anchorNotFound": "Якорный узел не найден",
+ "nameAlreadyExists": "Имя уже существует"
+ },
"dashboard": {
"notFound": "Панель управления не найдена"
},
diff --git a/packages/common-i18n/src/locales/tr/common.json b/packages/common-i18n/src/locales/tr/common.json
index 4023cb9bc3..5304d7c754 100644
--- a/packages/common-i18n/src/locales/tr/common.json
+++ b/packages/common-i18n/src/locales/tr/common.json
@@ -168,12 +168,24 @@
"dashboard": "Gösterge Paneli",
"automation": "Otomasyon",
"authorityMatrix": "Yetki Matrisi",
+ "design": "Tasarım",
"adminPanel": "Yönetici Paneli",
- "license": "Lisans",
+ "license": "Kendi sunuculu lisans",
"instanceId": "Örnek Kimliği",
"beta": "Beta",
"trash": "Çöp Kutusu",
- "global": "Genel"
+ "global": "Genel",
+ "organizationPanel": "Organizasyon Paneli",
+ "unknownError": "Bilinmeyen hata",
+ "pluginPanel": "Panel",
+ "pluginContextMenu": "Bağlam menüsü",
+ "plugin": "Eklentiler",
+ "copy": "Kopya",
+ "credits": "Krediler",
+ "aiChat": "AI Sohbet",
+ "app": "Uygulama",
+ "webSearch": "Web araması",
+ "folder": "Klasör"
},
"level": {
"free": "Ücretsiz",
diff --git a/packages/common-i18n/src/locales/tr/dashboard.json b/packages/common-i18n/src/locales/tr/dashboard.json
index be59cbf220..bbec16e05c 100644
--- a/packages/common-i18n/src/locales/tr/dashboard.json
+++ b/packages/common-i18n/src/locales/tr/dashboard.json
@@ -11,5 +11,9 @@
"placeholder": "Gösterge paneli adını girin"
},
"findDashboard": "Gösterge paneli ara...",
- "expand": "Genişlet"
+ "expand": "Genişlet",
+ "deprecation": {
+ "title": "Gösterge paneli düğümü özelliği sonlandırılacak",
+ "description": "Size daha akıllı ve verimli bir deneyim sunmak için gösterge paneli düğümü özelliğinin desteğini sonlandıracağız. AI ile oluşturulan uygulamada AI aracılığıyla kolayca yeni gösterge panelleri oluşturabilirsiniz."
+ }
}
diff --git a/packages/common-i18n/src/locales/tr/sdk.json b/packages/common-i18n/src/locales/tr/sdk.json
index 2a3f332a55..e95087135c 100644
--- a/packages/common-i18n/src/locales/tr/sdk.json
+++ b/packages/common-i18n/src/locales/tr/sdk.json
@@ -984,6 +984,24 @@
"baseAndSpaceMismatch": "Base {{baseId}} ve alan {{spaceId}} eşleşmiyor",
"templateNotFound": "Şablon {{templateId}} bulunamadı"
},
+ "baseNode": {
+ "invalidResourceType": "Geçersiz kaynak türü",
+ "notFound": "Temel düğüm bulunamadı",
+ "parentMustBeFolder": "Üst öğe bir klasör olmalıdır",
+ "cannotDuplicateFolder": "Klasör çoğaltılamaz",
+ "cannotDeleteEmptyFolder": "Klasör boş olmadığı için silinemez",
+ "onlyOneOfParentIdOrAnchorIdRequired": "Yalnızca parentId veya anchorId belirtilmelidir",
+ "cannotMoveToItself": "Düğüm kendisine taşınamaz",
+ "cannotMoveToCircularReference": "Düğüm kendi alt düğümüne taşınamaz (döngüsel referans)",
+ "anchorIdOrParentIdRequired": "En az parentId veya anchorId belirtilmelidir",
+ "parentNotFound": "Üst düğüm bulunamadı",
+ "parentIsNotFolder": "Üst öğe bir klasör değil",
+ "circularReference": "Döngüsel referans algılandı",
+ "folderDepthLimitExceeded": "Klasör derinlik sınırı aşıldı",
+ "folderNotFound": "Klasör bulunamadı",
+ "anchorNotFound": "Bağlantı düğümü bulunamadı",
+ "nameAlreadyExists": "İsim zaten mevcut"
+ },
"dashboard": {
"notFound": "Gösterge paneli bulunamadı"
},
diff --git a/packages/common-i18n/src/locales/uk/common.json b/packages/common-i18n/src/locales/uk/common.json
index 5c5659928a..e307bab63e 100644
--- a/packages/common-i18n/src/locales/uk/common.json
+++ b/packages/common-i18n/src/locales/uk/common.json
@@ -182,14 +182,24 @@
"dashboard": "Дашборд",
"automation": "Автоматизація",
"authorityMatrix": "Матриця повноважень",
+ "design": "Дизайн",
"adminPanel": "Панель адміністратора",
- "license": "Ліцензія",
+ "license": "Самостійна ліцензія",
"instanceId": "Ідентифікатор екземпляра",
"beta": "Бета",
"trash": "Кошик",
"global": "Глобальний",
"organizationPanel": "Організаційна панель",
- "unknownError": "Невідома помилка"
+ "unknownError": "Невідома помилка",
+ "pluginPanel": "Панель",
+ "pluginContextMenu": "Контекстне меню",
+ "plugin": "Плагіни",
+ "copy": "Копія",
+ "credits": "Кредити",
+ "aiChat": "ШІ-чат",
+ "app": "Додаток",
+ "webSearch": "Веб-пошук",
+ "folder": "Папка"
},
"level": {
"free": "Безкоштовно",
diff --git a/packages/common-i18n/src/locales/uk/dashboard.json b/packages/common-i18n/src/locales/uk/dashboard.json
index 1c46464ee6..6e964e4f2f 100644
--- a/packages/common-i18n/src/locales/uk/dashboard.json
+++ b/packages/common-i18n/src/locales/uk/dashboard.json
@@ -11,5 +11,9 @@
"placeholder": "Введіть назву дашборду"
},
"findDashboard": "Знайти дашборд...",
- "expand": "Розгорнути"
+ "expand": "Розгорнути",
+ "deprecation": {
+ "title": "Функцію вузла дашборду буде припинено",
+ "description": "Щоб забезпечити вам розумніший та ефективніший досвід, ми припинимо підтримку функції вузла дашборду. Ви зможете легко створювати нові дашборди за допомогою ШІ в додатку, створеному ШІ."
+ }
}
diff --git a/packages/common-i18n/src/locales/uk/sdk.json b/packages/common-i18n/src/locales/uk/sdk.json
index 6b0bcee1e4..c6c6f6f038 100644
--- a/packages/common-i18n/src/locales/uk/sdk.json
+++ b/packages/common-i18n/src/locales/uk/sdk.json
@@ -984,6 +984,24 @@
"baseAndSpaceMismatch": "База {{baseId}} та простір {{spaceId}} не співпадають",
"templateNotFound": "Шаблон {{templateId}} не знайдено"
},
+ "baseNode": {
+ "invalidResourceType": "Недійсний тип ресурсу",
+ "notFound": "Базовий вузол не знайдено",
+ "parentMustBeFolder": "Батьківський елемент має бути папкою",
+ "cannotDuplicateFolder": "Неможливо дублювати папку",
+ "cannotDeleteEmptyFolder": "Неможливо видалити папку, оскільки вона не порожня",
+ "onlyOneOfParentIdOrAnchorIdRequired": "Необхідно вказати лише parentId або anchorId",
+ "cannotMoveToItself": "Неможливо перемістити вузол на себе",
+ "cannotMoveToCircularReference": "Неможливо перемістити вузол до власного дочірнього вузла (циклічне посилання)",
+ "anchorIdOrParentIdRequired": "Необхідно вказати принаймні parentId або anchorId",
+ "parentNotFound": "Батьківський вузол не знайдено",
+ "parentIsNotFolder": "Батьківський елемент не є папкою",
+ "circularReference": "Виявлено циклічне посилання",
+ "folderDepthLimitExceeded": "Перевищено ліміт глибини папки",
+ "folderNotFound": "Папку не знайдено",
+ "anchorNotFound": "Якірний вузол не знайдено",
+ "nameAlreadyExists": "Ім'я вже існує"
+ },
"dashboard": {
"notFound": "Панель управління не знайдена"
},
diff --git a/packages/common-i18n/src/locales/zh/common.json b/packages/common-i18n/src/locales/zh/common.json
index 44ac9a2024..f585887798 100644
--- a/packages/common-i18n/src/locales/zh/common.json
+++ b/packages/common-i18n/src/locales/zh/common.json
@@ -282,7 +282,8 @@
"credits": "算力",
"aiChat": "AI 对话",
"app": "应用",
- "webSearch": "网络搜索"
+ "webSearch": "网络搜索",
+ "folder": "文件夹"
},
"level": {
"free": "免费版",
diff --git a/packages/common-i18n/src/locales/zh/dashboard.json b/packages/common-i18n/src/locales/zh/dashboard.json
index 57736df4d5..ee446cbd52 100644
--- a/packages/common-i18n/src/locales/zh/dashboard.json
+++ b/packages/common-i18n/src/locales/zh/dashboard.json
@@ -10,5 +10,9 @@
"title": "创建新仪表盘",
"placeholder": "输入仪表盘名称"
},
- "findDashboard": "查找仪表盘..."
+ "findDashboard": "查找仪表盘...",
+ "deprecation": {
+ "title": "仪表盘节点功能将停止支持",
+ "description": "为了给你带来更智能、更高效的体验,我们将停止仪表盘节点功能的支持。后续你可以在 AI 生成应用中通过 AI 轻松创建新的仪表盘,体验更流畅。"
+ }
}
diff --git a/packages/common-i18n/src/locales/zh/sdk.json b/packages/common-i18n/src/locales/zh/sdk.json
index 8b2e672581..ee8e49ca14 100644
--- a/packages/common-i18n/src/locales/zh/sdk.json
+++ b/packages/common-i18n/src/locales/zh/sdk.json
@@ -984,6 +984,24 @@
"baseAndSpaceMismatch": "数据库 {{baseId}} 与空间 {{spaceId}} 不匹配",
"templateNotFound": "模板 {{templateId}} 不存在"
},
+ "baseNode": {
+ "invalidResourceType": "无效的资源类型",
+ "notFound": "节点未找到",
+ "parentMustBeFolder": "父级必须是文件夹",
+ "cannotDuplicateFolder": "不能复制文件夹",
+ "cannotDeleteEmptyFolder": "不能删除非空文件夹",
+ "onlyOneOfParentIdOrAnchorIdRequired": "只能提供 parentId 或 anchorId 中的一个",
+ "cannotMoveToItself": "不能移动到自身",
+ "cannotMoveToCircularReference": "不能移动节点到其子节点(循环引用)",
+ "anchorIdOrParentIdRequired": "必须提供 anchorId 或 parentId",
+ "parentNotFound": "父级节点未找到",
+ "parentIsNotFolder": "父级不是文件夹",
+ "circularReference": "检测到循环引用",
+ "folderDepthLimitExceeded": "文件夹深度超限",
+ "folderNotFound": "文件夹未找到",
+ "anchorNotFound": "锚点节点未找到",
+ "nameAlreadyExists": "名称已存在"
+ },
"dashboard": {
"notFound": "仪表板不存在"
},
diff --git a/packages/core/src/auth/actions.ts b/packages/core/src/auth/actions.ts
index ddcfad2040..7706214e06 100644
--- a/packages/core/src/auth/actions.ts
+++ b/packages/core/src/auth/actions.ts
@@ -44,6 +44,15 @@ export const baseActions = [
export const baseActionSchema = z.enum(baseActions);
export type BaseAction = z.infer;
+export const baseNodeActions = [
+ 'base_node|read',
+ 'base_node|create',
+ 'base_node|update',
+ 'base_node|delete',
+] as const;
+export const baseNodeActionSchema = z.enum(baseNodeActions);
+export type BaseNodeAction = z.infer;
+
export const tableActions = [
'table|create',
'table|delete',
diff --git a/packages/core/src/models/channel.ts b/packages/core/src/models/channel.ts
index 948ee07a8a..db89e27dd2 100644
--- a/packages/core/src/models/channel.ts
+++ b/packages/core/src/models/channel.ts
@@ -41,3 +41,7 @@ export function getToolCallChannel(toolCallId: string) {
export function getChatChannel(chatId: string) {
return `__chat_${chatId}`;
}
+
+export function getBaseNodeChannel(baseId: string) {
+ return `__base_node_${baseId}`;
+}
diff --git a/packages/core/src/utils/id-generator.ts b/packages/core/src/utils/id-generator.ts
index 9d02c74a00..1c8a3f3683 100644
--- a/packages/core/src/utils/id-generator.ts
+++ b/packages/core/src/utils/id-generator.ts
@@ -4,6 +4,9 @@ export enum IdPrefix {
Space = 'spc',
Base = 'bse',
+ BaseNode = 'bno',
+ BaseNodeFolder = 'bnf',
+
Table = 'tbl',
Field = 'fld',
View = 'viw',
@@ -86,6 +89,14 @@ export function getRandomString(len: number, type: RandomType = RandomType.Strin
return nanoid(len);
}
+export function generateBaseNodeId() {
+ return IdPrefix.BaseNode + getRandomString(16);
+}
+
+export function generateBaseNodeFolderId() {
+ return IdPrefix.BaseNodeFolder + getRandomString(16);
+}
+
export function generateTableId() {
return IdPrefix.Table + getRandomString(16);
}
diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20251119134101_add_base_node/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20251119134101_add_base_node/migration.sql
new file mode 100644
index 0000000000..1be59d472a
--- /dev/null
+++ b/packages/db-main-prisma/prisma/postgres/migrations/20251119134101_add_base_node/migration.sql
@@ -0,0 +1,105 @@
+-- CreateTable
+CREATE TABLE "base_node" (
+ "id" TEXT NOT NULL,
+ "parent_id" TEXT,
+ "base_id" TEXT NOT NULL,
+ "resource_type" TEXT NOT NULL,
+ "resource_id" TEXT NOT NULL,
+ "order" DOUBLE PRECISION NOT NULL,
+ "created_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "created_by" TEXT NOT NULL,
+ "last_modified_time" TIMESTAMP(3),
+ "last_modified_by" TEXT,
+
+ CONSTRAINT "base_node_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "base_node_folder" (
+ "id" TEXT NOT NULL,
+ "base_id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "created_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "created_by" TEXT NOT NULL,
+ "last_modified_time" TIMESTAMP(3),
+ "last_modified_by" TEXT,
+
+ CONSTRAINT "base_node_folder_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "base_node_base_id_resource_type_resource_id_key" ON "base_node"("base_id", "resource_type", "resource_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "base_node_folder_base_id_name_key" ON "base_node_folder"("base_id", "name");
+
+-- AddForeignKey
+ALTER TABLE "base_node" ADD CONSTRAINT "base_node_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "base_node"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- Data Migration
+DO $$
+DECLARE
+ has_app BOOLEAN;
+ has_workflow BOOLEAN;
+ has_dashboard BOOLEAN;
+ has_table_meta BOOLEAN;
+ select_sql TEXT := '';
+ insert_sql TEXT;
+ first_select BOOLEAN := FALSE;
+BEGIN
+ -- Check for tables existence with schema filter
+ SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'app' AND table_schema = current_schema()) INTO has_app;
+ SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'workflow' AND table_schema = current_schema()) INTO has_workflow;
+ SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'dashboard' AND table_schema = current_schema()) INTO has_dashboard;
+ SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'table_meta' AND table_schema = current_schema()) INTO has_table_meta;
+
+ -- 1. Build select SQL for all resources
+ -- dashboard and app: sort by last_modified_time DESC (newer first), use negative epoch
+ -- workflow and table_meta: sort by order ASC (smaller first)
+ IF has_dashboard THEN
+ select_sql := 'SELECT base_id, ''dashboard'' as resource_type, id as resource_id, created_time, last_modified_time, -COALESCE(EXTRACT(EPOCH FROM last_modified_time), 0) as sort_value FROM "dashboard"';
+ first_select := TRUE;
+ END IF;
+
+ IF has_app THEN
+ IF first_select THEN
+ select_sql := select_sql || ' UNION ALL ';
+ END IF;
+ select_sql := select_sql || 'SELECT base_id, ''app'' as resource_type, id as resource_id, created_time, last_modified_time, -COALESCE(EXTRACT(EPOCH FROM last_modified_time), 0) as sort_value FROM "app" WHERE deleted_time IS NULL';
+ first_select := TRUE;
+ END IF;
+
+ IF has_workflow THEN
+ IF first_select THEN
+ select_sql := select_sql || ' UNION ALL ';
+ END IF;
+ select_sql := select_sql || 'SELECT base_id, ''workflow'' as resource_type, id as resource_id, created_time, last_modified_time, COALESCE("order", 0) as sort_value FROM "workflow" WHERE deleted_time IS NULL';
+ first_select := TRUE;
+ END IF;
+
+ IF has_table_meta THEN
+ IF first_select THEN
+ select_sql := select_sql || ' UNION ALL ';
+ END IF;
+ select_sql := select_sql || 'SELECT base_id, ''table'' as resource_type, id as resource_id, created_time, last_modified_time, COALESCE("order", 0) as sort_value FROM "table_meta" WHERE deleted_time IS NULL';
+ first_select := TRUE;
+ END IF;
+
+ -- 2. Build insert SQL with the select query
+ IF first_select THEN
+ insert_sql := '
+ INSERT INTO "base_node" ("id", "base_id", "resource_type", "resource_id", "order", "created_by", "created_time", "last_modified_time")
+ SELECT
+ gen_random_uuid(),
+ base_id,
+ resource_type,
+ resource_id,
+ row_number() OVER (PARTITION BY base_id ORDER BY sort_value ASC NULLS LAST),
+ ''anonymous'',
+ created_time,
+ last_modified_time
+ FROM (' || select_sql || ') as all_resources';
+
+ EXECUTE insert_sql;
+ END IF;
+END $$;
\ No newline at end of file
diff --git a/packages/db-main-prisma/prisma/postgres/schema.prisma b/packages/db-main-prisma/prisma/postgres/schema.prisma
index 6c230871a6..01c9c1e825 100644
--- a/packages/db-main-prisma/prisma/postgres/schema.prisma
+++ b/packages/db-main-prisma/prisma/postgres/schema.prisma
@@ -691,3 +691,40 @@ model Waitlist {
@@map("waitlist")
}
+
+
+model BaseNode {
+ id String @id @default(cuid())
+ parentId String? @map("parent_id")
+ baseId String @map("base_id")
+ resourceType String @map("resource_type")
+ resourceId String @map("resource_id")
+ order Float @map("order")
+
+ createdTime DateTime @default(now()) @map("created_time")
+ createdBy String @map("created_by")
+ lastModifiedTime DateTime? @updatedAt @map("last_modified_time")
+ lastModifiedBy String? @map("last_modified_by")
+
+ parent BaseNode? @relation("NodeToChildren", fields: [parentId], references: [id])
+ children BaseNode[] @relation("NodeToChildren")
+
+ @@unique([baseId, resourceType, resourceId])
+
+ @@map("base_node")
+}
+
+model BaseNodeFolder {
+ id String @id @default(cuid())
+ baseId String @map("base_id")
+ name String @map("name")
+
+ createdTime DateTime @default(now()) @map("created_time")
+ createdBy String @map("created_by")
+ lastModifiedTime DateTime? @updatedAt @map("last_modified_time")
+ lastModifiedBy String? @map("last_modified_by")
+
+ @@unique([baseId, name])
+
+ @@map("base_node_folder")
+}
diff --git a/packages/db-main-prisma/prisma/sqlite/migrations/20251119134053_add_base_node/migration.sql b/packages/db-main-prisma/prisma/sqlite/migrations/20251119134053_add_base_node/migration.sql
new file mode 100644
index 0000000000..9f5eef6279
--- /dev/null
+++ b/packages/db-main-prisma/prisma/sqlite/migrations/20251119134053_add_base_node/migration.sql
@@ -0,0 +1,31 @@
+-- CreateTable
+CREATE TABLE "base_node" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "parent_id" TEXT,
+ "base_id" TEXT NOT NULL,
+ "resource_type" TEXT NOT NULL,
+ "resource_id" TEXT NOT NULL,
+ "order" REAL NOT NULL,
+ "created_time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "created_by" TEXT NOT NULL,
+ "last_modified_time" DATETIME,
+ "last_modified_by" TEXT,
+ CONSTRAINT "base_node_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "base_node" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+
+-- CreateTable
+CREATE TABLE "base_node_folder" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "base_id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "created_time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "created_by" TEXT NOT NULL,
+ "last_modified_time" DATETIME,
+ "last_modified_by" TEXT
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "base_node_base_id_resource_type_resource_id_key" ON "base_node"("base_id", "resource_type", "resource_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "base_node_folder_base_id_name_key" ON "base_node_folder"("base_id", "name");
diff --git a/packages/db-main-prisma/prisma/sqlite/schema.prisma b/packages/db-main-prisma/prisma/sqlite/schema.prisma
index f78fd323a6..e8838aa3f8 100644
--- a/packages/db-main-prisma/prisma/sqlite/schema.prisma
+++ b/packages/db-main-prisma/prisma/sqlite/schema.prisma
@@ -691,3 +691,40 @@ model Waitlist {
@@map("waitlist")
}
+
+
+model BaseNode {
+ id String @id @default(cuid())
+ parentId String? @map("parent_id")
+ baseId String @map("base_id")
+ resourceType String @map("resource_type")
+ resourceId String @map("resource_id")
+ order Float @map("order")
+
+ createdTime DateTime @default(now()) @map("created_time")
+ createdBy String @map("created_by")
+ lastModifiedTime DateTime? @updatedAt @map("last_modified_time")
+ lastModifiedBy String? @map("last_modified_by")
+
+ parent BaseNode? @relation("NodeToChildren", fields: [parentId], references: [id])
+ children BaseNode[] @relation("NodeToChildren")
+
+ @@unique([baseId, resourceType, resourceId])
+
+ @@map("base_node")
+}
+
+model BaseNodeFolder {
+ id String @id @default(cuid())
+ baseId String @map("base_id")
+ name String @map("name")
+
+ createdTime DateTime @default(now()) @map("created_time")
+ createdBy String @map("created_by")
+ lastModifiedTime DateTime? @updatedAt @map("last_modified_time")
+ lastModifiedBy String? @map("last_modified_by")
+
+ @@unique([baseId, name])
+
+ @@map("base_node_folder")
+}
diff --git a/packages/db-main-prisma/prisma/template.prisma b/packages/db-main-prisma/prisma/template.prisma
index 73d32d4cc5..183d468cf2 100644
--- a/packages/db-main-prisma/prisma/template.prisma
+++ b/packages/db-main-prisma/prisma/template.prisma
@@ -691,3 +691,40 @@ model Waitlist {
@@map("waitlist")
}
+
+
+model BaseNode {
+ id String @id @default(cuid())
+ parentId String? @map("parent_id")
+ baseId String @map("base_id")
+ resourceType String @map("resource_type")
+ resourceId String @map("resource_id")
+ order Float @map("order")
+
+ createdTime DateTime @default(now()) @map("created_time")
+ createdBy String @map("created_by")
+ lastModifiedTime DateTime? @updatedAt @map("last_modified_time")
+ lastModifiedBy String? @map("last_modified_by")
+
+ parent BaseNode? @relation("NodeToChildren", fields: [parentId], references: [id])
+ children BaseNode[] @relation("NodeToChildren")
+
+ @@unique([baseId, resourceType, resourceId])
+
+ @@map("base_node")
+}
+
+model BaseNodeFolder {
+ id String @id @default(cuid())
+ baseId String @map("base_id")
+ name String @map("name")
+
+ createdTime DateTime @default(now()) @map("created_time")
+ createdBy String @map("created_by")
+ lastModifiedTime DateTime? @updatedAt @map("last_modified_time")
+ lastModifiedBy String? @map("last_modified_by")
+
+ @@unique([baseId, name])
+
+ @@map("base_node_folder")
+}
diff --git a/packages/openapi/src/admin/setting/get-public.ts b/packages/openapi/src/admin/setting/get-public.ts
index 54dc5cdbf9..7fd2d16c98 100644
--- a/packages/openapi/src/admin/setting/get-public.ts
+++ b/packages/openapi/src/admin/setting/get-public.ts
@@ -33,6 +33,7 @@ export const publicSettingVoSchema = settingVoSchema
disallowSignUp: true,
disallowSpaceCreation: true,
disallowSpaceInvitation: true,
+ disallowDashboard: true,
enableEmailVerification: true,
enableWaitlist: true,
createdTime: true,
diff --git a/packages/openapi/src/admin/setting/get.ts b/packages/openapi/src/admin/setting/get.ts
index 3d97345453..2f18435171 100644
--- a/packages/openapi/src/admin/setting/get.ts
+++ b/packages/openapi/src/admin/setting/get.ts
@@ -12,6 +12,7 @@ export const settingVoSchema = z.object({
disallowSignUp: z.boolean().nullable().optional(),
disallowSpaceCreation: z.boolean().nullable().optional(),
disallowSpaceInvitation: z.boolean().nullable().optional(),
+ disallowDashboard: z.boolean().nullable().optional(),
enableEmailVerification: z.boolean().nullable().optional(),
enableWaitlist: z.boolean().nullable().optional(),
aiConfig: aiConfigVoSchema.nullable().optional(),
diff --git a/packages/openapi/src/admin/setting/key.enum.ts b/packages/openapi/src/admin/setting/key.enum.ts
index 2586eaa1eb..bc8103cd3f 100644
--- a/packages/openapi/src/admin/setting/key.enum.ts
+++ b/packages/openapi/src/admin/setting/key.enum.ts
@@ -5,6 +5,7 @@ export enum SettingKey {
DISALLOW_SIGN_UP = 'disallowSignUp',
DISALLOW_SPACE_CREATION = 'disallowSpaceCreation',
DISALLOW_SPACE_INVITATION = 'disallowSpaceInvitation',
+ DISALLOW_DASHBOARD = 'disallowDashboard',
ENABLE_EMAIL_VERIFICATION = 'enableEmailVerification',
ENABLE_WAITLIST = 'enableWaitlist',
AI_CONFIG = 'aiConfig',
diff --git a/packages/openapi/src/base-node/create.ts b/packages/openapi/src/base-node/create.ts
new file mode 100644
index 0000000000..169b433487
--- /dev/null
+++ b/packages/openapi/src/base-node/create.ts
@@ -0,0 +1,99 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../axios';
+import { createDashboardRoSchema } from '../dashboard/create';
+import { tableRoWithDefaultSchema } from '../table/create';
+import { registerRoute, urlBuilder } from '../utils';
+import { z } from '../zod';
+import { createBaseNodeFolderRoSchema } from './folder/create';
+import { baseNodeVoSchema, BaseNodeResourceType, type IBaseNodeVo } from './types';
+
+export const CREATE_BASE_NODE = '/base/{baseId}/node';
+
+const createBaseNodeSchema = z.object({
+ resourceType: z.nativeEnum(BaseNodeResourceType),
+ parentId: z.string().nullable().optional(),
+ name: z.string().trim().min(1),
+});
+
+export type ICreateBaseNode = z.infer;
+
+const createBaseFolderNodeRoSchema = z.object({
+ ...createBaseNodeSchema.shape,
+ resourceType: z.literal(BaseNodeResourceType.Folder),
+ ...createBaseNodeFolderRoSchema.shape,
+});
+
+export type ICreateFolderNodeRo = z.infer;
+
+const createBaseTableNodeRoSchema = z.object({
+ ...createBaseNodeSchema.shape,
+ resourceType: z.literal(BaseNodeResourceType.Table),
+ ...tableRoWithDefaultSchema.shape,
+});
+
+export type ICreateTableNodeRo = z.infer;
+
+const createBaseDashboardNodeRoSchema = z.object({
+ ...createBaseNodeSchema.shape,
+ resourceType: z.literal(BaseNodeResourceType.Dashboard),
+ ...createDashboardRoSchema.shape,
+});
+
+export type ICreateDashboardNodeRo = z.infer;
+
+const createBaseWorkflowNodeRoSchema = z.object({
+ ...createBaseNodeSchema.shape,
+ resourceType: z.literal(BaseNodeResourceType.Workflow),
+});
+
+export type ICreateWorkflowNodeRo = z.infer;
+
+const createBaseAppNodeRoSchema = z.object({
+ ...createBaseNodeSchema.shape,
+ resourceType: z.literal(BaseNodeResourceType.App),
+});
+
+export type ICreateAppNodeRo = z.infer;
+
+export const createBaseNodeRoSchema = z.discriminatedUnion('resourceType', [
+ createBaseFolderNodeRoSchema,
+ createBaseTableNodeRoSchema,
+ createBaseDashboardNodeRoSchema,
+ createBaseWorkflowNodeRoSchema,
+ createBaseAppNodeRoSchema,
+]);
+
+export type ICreateBaseNodeRo = z.infer;
+
+export const CreateBaseNodeRoute: RouteConfig = registerRoute({
+ method: 'post',
+ path: CREATE_BASE_NODE,
+ description: 'Create a hierarchical node for a base',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ }),
+ body: {
+ content: {
+ 'application/json': {
+ schema: createBaseNodeRoSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Created node',
+ content: {
+ 'application/json': {
+ schema: baseNodeVoSchema,
+ },
+ },
+ },
+ },
+ tags: ['base node'],
+});
+
+export const createBaseNode = async (baseId: string, ro: ICreateBaseNodeRo) => {
+ return axios.post(urlBuilder(CREATE_BASE_NODE, { baseId }), ro);
+};
diff --git a/packages/openapi/src/base-node/delete.ts b/packages/openapi/src/base-node/delete.ts
new file mode 100644
index 0000000000..efe9bced88
--- /dev/null
+++ b/packages/openapi/src/base-node/delete.ts
@@ -0,0 +1,28 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../axios';
+import { registerRoute, urlBuilder } from '../utils';
+import { z } from '../zod';
+
+export const DELETE_BASE_NODE = '/base/{baseId}/node/{nodeId}';
+
+export const DeleteBaseNodeRoute: RouteConfig = registerRoute({
+ method: 'delete',
+ path: DELETE_BASE_NODE,
+ description: 'Delete a node for a base',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ nodeId: z.string(),
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'Deleted node Successfully',
+ },
+ },
+ tags: ['base node'],
+});
+
+export const deleteBaseNode = async (baseId: string, nodeId: string) => {
+ return axios.delete(urlBuilder(DELETE_BASE_NODE, { baseId, nodeId }));
+};
diff --git a/packages/openapi/src/base-node/duplicate.ts b/packages/openapi/src/base-node/duplicate.ts
new file mode 100644
index 0000000000..6fbfe0d6b5
--- /dev/null
+++ b/packages/openapi/src/base-node/duplicate.ts
@@ -0,0 +1,60 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../axios';
+import { duplicateDashboardRoSchema } from '../dashboard';
+import { duplicateTableRoSchema } from '../table';
+import { registerRoute, urlBuilder } from '../utils';
+import { z } from '../zod';
+import { baseNodeVoSchema, type IBaseNodeVo } from './types';
+
+export const DUPLICATE_BASE_NODE = '/base/{baseId}/node/{nodeId}/duplicate';
+
+// workflow and app use the same schema
+export const duplicateNodeRoSchema = z.object({
+ name: z.string().trim().optional(),
+});
+
+export const duplicateBaseNodeRoSchema = z.union([
+ duplicateTableRoSchema,
+ duplicateNodeRoSchema,
+ duplicateDashboardRoSchema,
+]);
+
+export type IDuplicateBaseNodeRo = z.infer;
+
+export const DuplicateBaseNodeRoute: RouteConfig = registerRoute({
+ method: 'post',
+ path: DUPLICATE_BASE_NODE,
+ description: 'Duplicate a node for a base',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ nodeId: z.string(),
+ }),
+ body: {
+ content: {
+ 'application/json': {
+ schema: duplicateBaseNodeRoSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Duplicated node',
+ content: {
+ 'application/json': {
+ schema: baseNodeVoSchema,
+ },
+ },
+ },
+ },
+ tags: ['base node'],
+});
+
+export const duplicateBaseNode = async (
+ baseId: string,
+ nodeId: string,
+ ro: IDuplicateBaseNodeRo
+) => {
+ return axios.post(urlBuilder(DUPLICATE_BASE_NODE, { baseId, nodeId }), ro);
+};
diff --git a/packages/openapi/src/base-node/folder/create.ts b/packages/openapi/src/base-node/folder/create.ts
new file mode 100644
index 0000000000..d8036883f4
--- /dev/null
+++ b/packages/openapi/src/base-node/folder/create.ts
@@ -0,0 +1,52 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../../axios';
+import { registerRoute, urlBuilder } from '../../utils';
+import { z } from '../../zod';
+
+export const CREATE_BASE_NODE_FOLDER = '/base/{baseId}/node/folder';
+
+export const createBaseNodeFolderRoSchema = z.object({
+ name: z.string().trim().min(1),
+});
+
+export type ICreateBaseNodeFolderRo = z.infer;
+
+export const createBaseNodeFolderVoSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+});
+
+export type ICreateBaseNodeFolderVo = z.infer;
+
+export const CreateBaseNodeFolderRoute: RouteConfig = registerRoute({
+ method: 'post',
+ path: CREATE_BASE_NODE_FOLDER,
+ description: 'Create a folder node in base',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ }),
+ body: {
+ content: {
+ 'application/json': {
+ schema: createBaseNodeFolderRoSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Created folder node',
+ content: {
+ 'application/json': {
+ schema: createBaseNodeFolderVoSchema,
+ },
+ },
+ },
+ },
+ tags: ['base node'],
+});
+
+export const createBaseNodeFolder = async (baseId: string, ro: ICreateBaseNodeFolderRo) => {
+ return axios.post(urlBuilder(CREATE_BASE_NODE_FOLDER, { baseId }), ro);
+};
diff --git a/packages/openapi/src/base-node/folder/delete.ts b/packages/openapi/src/base-node/folder/delete.ts
new file mode 100644
index 0000000000..8a809fc621
--- /dev/null
+++ b/packages/openapi/src/base-node/folder/delete.ts
@@ -0,0 +1,28 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../../axios';
+import { registerRoute, urlBuilder } from '../../utils';
+import { z } from '../../zod';
+
+export const DELETE_BASE_NODE_FOLDER = '/base/{baseId}/node/folder/{folderId}';
+
+export const DeleteBaseNodeFolderRoute: RouteConfig = registerRoute({
+ method: 'delete',
+ path: DELETE_BASE_NODE_FOLDER,
+ description: 'Delete a node folder and move its children to parent',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ folderId: z.string(),
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'Deleted folder node (for client side cleanup)',
+ },
+ },
+ tags: ['base node'],
+});
+
+export const deleteBaseNodeFolder = async (baseId: string, folderId: string) => {
+ return axios.delete(urlBuilder(DELETE_BASE_NODE_FOLDER, { baseId, folderId }));
+};
diff --git a/packages/openapi/src/base-node/folder/update.ts b/packages/openapi/src/base-node/folder/update.ts
new file mode 100644
index 0000000000..3ac16f7913
--- /dev/null
+++ b/packages/openapi/src/base-node/folder/update.ts
@@ -0,0 +1,60 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../../axios';
+import { registerRoute, urlBuilder } from '../../utils';
+import { z } from '../../zod';
+
+export const UPDATE_BASE_NODE_FOLDER = '/base/{baseId}/node/folder/{folderId}';
+
+export const updateBaseNodeFolderRoSchema = z.object({
+ name: z.string().trim().min(1),
+});
+
+export type IUpdateBaseNodeFolderRo = z.infer;
+
+export const updateBaseNodeFolderVoSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+});
+
+export type IUpdateBaseNodeFolderVo = z.infer;
+
+export const UpdateBaseNodeFolderRoute: RouteConfig = registerRoute({
+ method: 'patch',
+ path: UPDATE_BASE_NODE_FOLDER,
+ description: 'Rename a node folder',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ folderId: z.string(),
+ }),
+ body: {
+ content: {
+ 'application/json': {
+ schema: updateBaseNodeFolderRoSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Updated node folder',
+ content: {
+ 'application/json': {
+ schema: updateBaseNodeFolderVoSchema,
+ },
+ },
+ },
+ },
+ tags: ['base node'],
+});
+
+export const updateBaseNodeFolder = async (
+ baseId: string,
+ folderId: string,
+ ro: IUpdateBaseNodeFolderRo
+) => {
+ return axios.patch(
+ urlBuilder(UPDATE_BASE_NODE_FOLDER, { baseId, folderId }),
+ ro
+ );
+};
diff --git a/packages/openapi/src/base-node/get-list.ts b/packages/openapi/src/base-node/get-list.ts
new file mode 100644
index 0000000000..812b6c5178
--- /dev/null
+++ b/packages/openapi/src/base-node/get-list.ts
@@ -0,0 +1,37 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../axios';
+import { registerRoute, urlBuilder } from '../utils';
+import { z } from '../zod';
+import { baseNodeVoSchema } from './types';
+
+export const GET_BASE_NODE_LIST = '/base/{baseId}/node/list';
+
+export const baseNodeListVoSchema = z.array(baseNodeVoSchema);
+
+export type IBaseNodeListVo = z.infer;
+
+export const GetBaseNodeListRoute: RouteConfig = registerRoute({
+ method: 'get',
+ path: GET_BASE_NODE_LIST,
+ description: 'Get list nodes of a base',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'List nodes',
+ content: {
+ 'application/json': {
+ schema: baseNodeListVoSchema,
+ },
+ },
+ },
+ },
+ tags: ['base node'],
+});
+
+export const getBaseNodeList = async (baseId: string) => {
+ return axios.get(urlBuilder(GET_BASE_NODE_LIST, { baseId }));
+};
diff --git a/packages/openapi/src/base-node/get-tree.ts b/packages/openapi/src/base-node/get-tree.ts
new file mode 100644
index 0000000000..68a6115051
--- /dev/null
+++ b/packages/openapi/src/base-node/get-tree.ts
@@ -0,0 +1,40 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../axios';
+import { registerRoute, urlBuilder } from '../utils';
+import { z } from '../zod';
+import { baseNodeVoSchema } from './types';
+
+export const GET_BASE_NODE_TREE = '/base/{baseId}/node/tree';
+
+export const baseNodeTreeVoSchema = z.object({
+ nodes: z.array(baseNodeVoSchema),
+ maxFolderDepth: z.number(),
+});
+
+export type IBaseNodeTreeVo = z.infer;
+
+export const GetBaseNodeTreeRoute: RouteConfig = registerRoute({
+ method: 'get',
+ path: GET_BASE_NODE_TREE,
+ description: 'Get tree nodes for a base',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'Nodes',
+ content: {
+ 'application/json': {
+ schema: baseNodeTreeVoSchema,
+ },
+ },
+ },
+ },
+ tags: ['base node'],
+});
+
+export const getBaseNodeTree = async (baseId: string) => {
+ return axios.get(urlBuilder(GET_BASE_NODE_TREE, { baseId }));
+};
diff --git a/packages/openapi/src/base-node/get.ts b/packages/openapi/src/base-node/get.ts
new file mode 100644
index 0000000000..33311aa9a1
--- /dev/null
+++ b/packages/openapi/src/base-node/get.ts
@@ -0,0 +1,35 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../axios';
+import { registerRoute, urlBuilder } from '../utils';
+import { z } from '../zod';
+import { baseNodeVoSchema } from './types';
+import type { IBaseNodeVo } from './types';
+
+export const GET_BASE_NODE = '/base/{baseId}/node/{nodeId}';
+
+export const GetBaseNodeRoute: RouteConfig = registerRoute({
+ method: 'get',
+ path: GET_BASE_NODE,
+ description: 'Get nodes for a base',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ nodeId: z.string(),
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'Nodes',
+ content: {
+ 'application/json': {
+ schema: baseNodeVoSchema,
+ },
+ },
+ },
+ },
+ tags: ['base node'],
+});
+
+export const getBaseNode = async (baseId: string, nodeId: string) => {
+ return axios.get(urlBuilder(GET_BASE_NODE, { baseId, nodeId }));
+};
diff --git a/packages/openapi/src/base-node/index.ts b/packages/openapi/src/base-node/index.ts
new file mode 100644
index 0000000000..f2cf2e8546
--- /dev/null
+++ b/packages/openapi/src/base-node/index.ts
@@ -0,0 +1,14 @@
+export * from './types';
+export * from './get';
+export * from './get-tree';
+export * from './get-list';
+export * from './move';
+export * from './create';
+export * from './duplicate';
+export * from './update';
+export * from './delete';
+export * from './permanent-delete';
+
+export * from './folder/create';
+export * from './folder/update';
+export * from './folder/delete';
diff --git a/packages/openapi/src/base-node/move.ts b/packages/openapi/src/base-node/move.ts
new file mode 100644
index 0000000000..0b345c09d8
--- /dev/null
+++ b/packages/openapi/src/base-node/move.ts
@@ -0,0 +1,49 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../axios';
+import { registerRoute, urlBuilder } from '../utils';
+import { z } from '../zod';
+import { baseNodeVoSchema, type IBaseNodeVo } from './types';
+
+export const MOVE_BASE_NODE = '/base/{baseId}/node/{nodeId}/move';
+
+export const moveBaseNodeRoSchema = z.object({
+ parentId: z.string().nullable().optional(),
+ anchorId: z.string().optional(),
+ position: z.enum(['before', 'after']).optional(),
+});
+
+export type IMoveBaseNodeRo = z.infer;
+
+export const MoveBaseNodeRoute: RouteConfig = registerRoute({
+ method: 'put',
+ path: MOVE_BASE_NODE,
+ description: 'Move or reorder a node',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ nodeId: z.string(),
+ }),
+ body: {
+ content: {
+ 'application/json': {
+ schema: moveBaseNodeRoSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Updated node info',
+ content: {
+ 'application/json': {
+ schema: baseNodeVoSchema,
+ },
+ },
+ },
+ },
+ tags: ['base node'],
+});
+
+export const moveBaseNode = async (baseId: string, nodeId: string, ro: IMoveBaseNodeRo) => {
+ return axios.put(urlBuilder(MOVE_BASE_NODE, { baseId, nodeId }), ro);
+};
diff --git a/packages/openapi/src/base-node/permanent-delete.ts b/packages/openapi/src/base-node/permanent-delete.ts
new file mode 100644
index 0000000000..a6edea5f95
--- /dev/null
+++ b/packages/openapi/src/base-node/permanent-delete.ts
@@ -0,0 +1,28 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../axios';
+import { registerRoute, urlBuilder } from '../utils';
+import { z } from '../zod';
+
+export const DELETE_BASE_NODE_PERMANENT = '/base/{baseId}/node/{nodeId}/permanent';
+
+export const PermanentDeleteBaseNodeRoute: RouteConfig = registerRoute({
+ method: 'delete',
+ path: DELETE_BASE_NODE_PERMANENT,
+ description: 'Permanent delete a node for a base',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ nodeId: z.string(),
+ }),
+ },
+ responses: {
+ 200: {
+ description: 'Permanent deleted node Successfully',
+ },
+ },
+ tags: ['base node'],
+});
+
+export const permanentDeleteBaseNode = async (baseId: string, nodeId: string) => {
+ return axios.delete(urlBuilder(DELETE_BASE_NODE_PERMANENT, { baseId, nodeId }));
+};
diff --git a/packages/openapi/src/base-node/types.ts b/packages/openapi/src/base-node/types.ts
new file mode 100644
index 0000000000..34b74a1ae3
--- /dev/null
+++ b/packages/openapi/src/base-node/types.ts
@@ -0,0 +1,124 @@
+import { z } from '../zod';
+
+export enum BaseNodeResourceType {
+ Table = 'table',
+ Dashboard = 'dashboard',
+ Workflow = 'workflow',
+ App = 'app',
+ Folder = 'folder',
+}
+
+export const baseNodeResourceTypeSchema = z.nativeEnum(BaseNodeResourceType);
+
+const defaultResourceMetaSchema = z.object({
+ name: z.string(),
+ icon: z.string().nullable().optional(),
+});
+
+export const baseNodeFolderResourceMetaSchema = defaultResourceMetaSchema;
+
+export type IBaseNodeFolderResourceMeta = z.infer;
+
+export const baseNodeTableResourceMetaSchema = defaultResourceMetaSchema.extend({
+ defaultViewId: z.string().nullable().optional(),
+});
+
+export type IBaseNodeTableResourceMeta = z.infer;
+
+export const baseNodeAppResourceMetaSchema = defaultResourceMetaSchema;
+
+export type IBaseNodeAppResourceMeta = z.infer;
+
+export const baseNodeDashboardResourceMetaSchema = defaultResourceMetaSchema;
+
+export type IBaseNodeDashboardResourceMeta = z.infer;
+
+export const baseNodeWorkflowResourceMetaSchema = defaultResourceMetaSchema.extend({
+ isActive: z.boolean().nullable().optional(),
+});
+
+export type IBaseNodeWorkflowResourceMeta = z.infer;
+
+const baseNodeResourceMetaSchema = z.union([
+ baseNodeWorkflowResourceMetaSchema,
+ baseNodeTableResourceMetaSchema,
+ baseNodeAppResourceMetaSchema,
+ baseNodeDashboardResourceMetaSchema,
+ baseNodeFolderResourceMetaSchema,
+]);
+
+export type IBaseNodeResourceMeta = z.infer;
+
+export type IBaseNodeResourceMetaWithId = IBaseNodeResourceMeta & { id: string };
+
+const baseNodeBaseSchema = z.object({
+ id: z.string(),
+ parentId: z.string().nullable(),
+ resourceId: z.string(),
+ order: z.number(),
+ parent: z
+ .object({
+ id: z.string(),
+ })
+ .nullable()
+ .optional(),
+ children: z
+ .array(
+ z.object({
+ id: z.string(),
+ order: z.number(),
+ })
+ )
+ .nullable()
+ .optional(),
+});
+
+export const baseNodeVoSchema = z.discriminatedUnion('resourceType', [
+ baseNodeBaseSchema.extend({
+ resourceType: z.literal(BaseNodeResourceType.Table),
+ resourceMeta: baseNodeTableResourceMetaSchema.optional(),
+ }),
+ baseNodeBaseSchema.extend({
+ resourceType: z.literal(BaseNodeResourceType.Dashboard),
+ resourceMeta: baseNodeDashboardResourceMetaSchema.optional(),
+ }),
+ baseNodeBaseSchema.extend({
+ resourceType: z.literal(BaseNodeResourceType.Workflow),
+ resourceMeta: baseNodeWorkflowResourceMetaSchema.optional(),
+ }),
+ baseNodeBaseSchema.extend({
+ resourceType: z.literal(BaseNodeResourceType.App),
+ resourceMeta: baseNodeAppResourceMetaSchema.optional(),
+ }),
+ baseNodeBaseSchema.extend({
+ resourceType: z.literal(BaseNodeResourceType.Folder),
+ resourceMeta: baseNodeFolderResourceMetaSchema.optional(),
+ }),
+]);
+
+export type IBaseNodeVo = z.infer;
+
+export type IBaseNodePresenceDeletePayload = {
+ event: 'delete';
+ data: Pick;
+};
+
+export type IBaseNodePresenceCreatePayload = {
+ event: 'create';
+ data: IBaseNodeVo;
+};
+
+export type IBaseNodePresenceUpdatePayload = {
+ event: 'update';
+ data: IBaseNodeVo;
+};
+
+export type IBaseNodePresenceFlushPayload = {
+ event: 'flush';
+};
+
+export type IBaseNodePresencePayload =
+ | IBaseNodePresenceCreatePayload
+ | IBaseNodePresenceUpdatePayload
+ | IBaseNodePresenceDeletePayload
+ | IBaseNodePresenceFlushPayload;
diff --git a/packages/openapi/src/base-node/update.ts b/packages/openapi/src/base-node/update.ts
new file mode 100644
index 0000000000..b0160a7068
--- /dev/null
+++ b/packages/openapi/src/base-node/update.ts
@@ -0,0 +1,49 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../axios';
+import { registerRoute, urlBuilder } from '../utils';
+import { z } from '../zod';
+import { baseNodeVoSchema } from './types';
+import type { IBaseNodeVo } from './types';
+
+export const UPDATE_BASE_NODE = '/base/{baseId}/node/{nodeId}';
+
+export const updateBaseNodeRoSchema = z.object({
+ name: z.string().trim().min(1).optional(),
+ icon: z.string().trim().optional(),
+});
+
+export type IUpdateBaseNodeRo = z.infer;
+
+export const UpdateBaseNodeRoute: RouteConfig = registerRoute({
+ method: 'put',
+ path: UPDATE_BASE_NODE,
+ description: 'Update a node for a base',
+ request: {
+ params: z.object({
+ baseId: z.string(),
+ nodeId: z.string(),
+ }),
+ body: {
+ content: {
+ 'application/json': {
+ schema: updateBaseNodeRoSchema,
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Updated node',
+ content: {
+ 'application/json': {
+ schema: baseNodeVoSchema,
+ },
+ },
+ },
+ },
+ tags: ['base node'],
+});
+
+export const updateBaseNode = async (baseId: string, nodeId: string, ro: IUpdateBaseNodeRo) => {
+ return axios.put(urlBuilder(UPDATE_BASE_NODE, { baseId, nodeId }), ro);
+};
diff --git a/packages/openapi/src/base/export.ts b/packages/openapi/src/base/export.ts
index 4fe9c8967c..08eb111ebf 100644
--- a/packages/openapi/src/base/export.ts
+++ b/packages/openapi/src/base/export.ts
@@ -1,6 +1,7 @@
import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
import { fieldVoSchema, IdPrefix, viewVoSchema } from '@teable/core';
import { axios } from '../axios';
+import { BaseNodeResourceType } from '../base-node/types';
import { pluginInstallStorageSchema } from '../dashboard';
import { PluginPosition } from '../plugin';
import { registerRoute, urlBuilder } from '../utils';
@@ -142,6 +143,31 @@ export const viewPluginJsonSchema = viewJsonSchema.extend({
}),
});
+export const folderJsonSchema = z.object({
+ id: z.string().openapi({
+ description: 'The id of the folder.',
+ }),
+ name: z.string().openapi({
+ description: 'The name of the folder.',
+ }),
+});
+
+export const nodeJsonSchema = z.object({
+ id: z.string().openapi({
+ description: 'The id of the node.',
+ }),
+ parentId: z.string().nullable().openapi({
+ description: 'The id of the parent node.',
+ }),
+ resourceId: z.string().openapi({
+ description: 'The id of the resource.',
+ }),
+ resourceType: z.nativeEnum(BaseNodeResourceType).openapi({
+ description: 'The type of the resource.',
+ }),
+ order: z.number(),
+});
+
export const pluginJsonSchema = z.object({
[PluginPosition.Dashboard]: dashboardJsonSchema.array(),
[PluginPosition.Panel]: pluginPanelJsonSchema.array(),
@@ -152,6 +178,8 @@ export const BaseJsonSchema = z.object({
name: z.string(),
icon: z.string().nullable(),
tables: tableJsonSchema.array(),
+ folders: folderJsonSchema.array(),
+ nodes: nodeJsonSchema.array(),
plugins: pluginJsonSchema,
version: z.string(),
});
diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts
index 96ddb7eaef..8e004970ef 100644
--- a/packages/openapi/src/index.ts
+++ b/packages/openapi/src/index.ts
@@ -42,3 +42,4 @@ export * from './automation';
export * from './mail';
export * from './formula';
export * from './unsubscribe';
+export * from './base-node';
diff --git a/packages/openapi/src/pin/types.ts b/packages/openapi/src/pin/types.ts
index b588f1509f..a4bcb14c19 100644
--- a/packages/openapi/src/pin/types.ts
+++ b/packages/openapi/src/pin/types.ts
@@ -3,4 +3,7 @@ export enum PinType {
Base = 'base',
Table = 'table',
View = 'view',
+ Dashboard = 'dashboard',
+ Workflow = 'workflow',
+ App = 'app',
}
diff --git a/packages/openapi/src/user/last-visit/get-base-node.ts b/packages/openapi/src/user/last-visit/get-base-node.ts
new file mode 100644
index 0000000000..0fec8f9b20
--- /dev/null
+++ b/packages/openapi/src/user/last-visit/get-base-node.ts
@@ -0,0 +1,43 @@
+import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
+import { axios } from '../../axios';
+import { registerRoute, urlBuilder } from '../../utils';
+import { z } from '../../zod';
+import { userLastVisitVoSchema } from './get';
+
+export const GET_USER_LAST_VISIT_BASE_NODE = '/user/last-visit/base-node';
+
+export const getUserLastVisitBaseNodeRoSchema = z.object({
+ parentResourceId: z.string(),
+});
+
+export type IGetUserLastVisitBaseNodeRo = z.infer;
+
+export const userLastVisitBaseNodeVoSchema = userLastVisitVoSchema.optional();
+
+export type IUserLastVisitBaseNodeVo = z.infer;
+
+export const GetUserLastVisitBaseNodeRoute: RouteConfig = registerRoute({
+ method: 'get',
+ path: GET_USER_LAST_VISIT_BASE_NODE,
+ description: 'Get user last visited base node',
+ request: {
+ query: getUserLastVisitBaseNodeRoSchema,
+ },
+ responses: {
+ 200: {
+ description: 'Returns data about user last visit base node.',
+ content: {
+ 'application/json': {
+ schema: userLastVisitBaseNodeVoSchema,
+ },
+ },
+ },
+ },
+ tags: ['user'],
+});
+
+export const getUserLastVisitBaseNode = async (params: IGetUserLastVisitBaseNodeRo) => {
+ return axios.get(urlBuilder(GET_USER_LAST_VISIT_BASE_NODE, {}), {
+ params,
+ });
+};
diff --git a/packages/openapi/src/user/last-visit/get.ts b/packages/openapi/src/user/last-visit/get.ts
index 21ba45bd5a..3518c14b49 100644
--- a/packages/openapi/src/user/last-visit/get.ts
+++ b/packages/openapi/src/user/last-visit/get.ts
@@ -11,6 +11,7 @@ export enum LastVisitResourceType {
View = 'view',
Dashboard = 'dashboard',
Automation = 'automation',
+ App = 'app',
}
export const userLastVisitVoSchema = z.object({
diff --git a/packages/openapi/src/user/last-visit/index.ts b/packages/openapi/src/user/last-visit/index.ts
index cd5358c5e6..c34d606258 100644
--- a/packages/openapi/src/user/last-visit/index.ts
+++ b/packages/openapi/src/user/last-visit/index.ts
@@ -2,3 +2,4 @@ export * from './get';
export * from './update';
export * from './getMap';
export * from './list-base';
+export * from './get-base-node';
diff --git a/packages/sdk/src/config/local-storage-keys.ts b/packages/sdk/src/config/local-storage-keys.ts
index 801120f407..00640245de 100644
--- a/packages/sdk/src/config/local-storage-keys.ts
+++ b/packages/sdk/src/config/local-storage-keys.ts
@@ -23,4 +23,5 @@ export enum LocalStorageKeys {
InteractionMode = 'ls_interaction_mode',
ChatPanel = 'ls_chat_panel',
Chat = 'ls_chat',
+ BaseNodeTreeExpandedItems = 'ls_base_node_tree_expanded_items',
}
diff --git a/packages/sdk/src/config/react-query-keys.ts b/packages/sdk/src/config/react-query-keys.ts
index ab5d315c8d..10c2f25658 100644
--- a/packages/sdk/src/config/react-query-keys.ts
+++ b/packages/sdk/src/config/react-query-keys.ts
@@ -222,4 +222,6 @@ export const ReactQueryKeys = {
oauthAppList: () => ['oauth-app-list'] as const,
oauthApp: (clientId: string) => ['oauth-app', clientId] as const,
+
+ baseNodeTree: (baseId: string) => ['base-node-tree', baseId] as const,
};
diff --git a/packages/ui-lib/package.json b/packages/ui-lib/package.json
index 6a7de98784..bda6223ce9 100644
--- a/packages/ui-lib/package.json
+++ b/packages/ui-lib/package.json
@@ -116,6 +116,8 @@
"@dnd-kit/sortable": "8.0.0",
"@dnd-kit/utilities": "3.2.2",
"@glideapps/glide-data-grid": "6.0.3",
+ "@headless-tree/core": "1.5.1",
+ "@headless-tree/react": "1.5.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "1.0.5",
"@radix-ui/react-avatar": "1.0.4",
diff --git a/packages/ui-lib/src/base/headless-tree/index.ts b/packages/ui-lib/src/base/headless-tree/index.ts
new file mode 100644
index 0000000000..381a67789c
--- /dev/null
+++ b/packages/ui-lib/src/base/headless-tree/index.ts
@@ -0,0 +1,2 @@
+export * from '@headless-tree/core';
+export * from '@headless-tree/react';
diff --git a/packages/ui-lib/src/base/index.ts b/packages/ui-lib/src/base/index.ts
index b7a6faab9b..ba9381ec10 100644
--- a/packages/ui-lib/src/base/index.ts
+++ b/packages/ui-lib/src/base/index.ts
@@ -5,3 +5,4 @@ export * from './file';
export * from './dialog';
export * from './dnd-kit';
export * from './Error';
+export * from './headless-tree';
diff --git a/packages/ui-lib/src/shadcn/ui/scroll-area.tsx b/packages/ui-lib/src/shadcn/ui/scroll-area.tsx
index 54be4c52dc..dcfac10bc1 100644
--- a/packages/ui-lib/src/shadcn/ui/scroll-area.tsx
+++ b/packages/ui-lib/src/shadcn/ui/scroll-area.tsx
@@ -7,17 +7,29 @@ import { cn } from '../utils';
const ScrollArea = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, onScroll, ...props }, ref) => (
+ React.ComponentPropsWithoutRef & {
+ scrollBar?: 'horizontal' | 'vertical' | 'both' | 'none';
+ viewportRef?: React.Ref;
+ }
+>(({ className, children, onScroll, scrollBar = 'vertical', viewportRef, ...props }, ref) => (
-
+
{children}
-
+ {scrollBar && (scrollBar === 'both' || scrollBar === 'vertical') && (
+
+ )}
+ {scrollBar && (scrollBar === 'both' || scrollBar === 'horizontal') && (
+
+ )}
));
diff --git a/packages/ui-lib/src/shadcn/ui/tree.tsx b/packages/ui-lib/src/shadcn/ui/tree.tsx
new file mode 100644
index 0000000000..1e11dcc45a
--- /dev/null
+++ b/packages/ui-lib/src/shadcn/ui/tree.tsx
@@ -0,0 +1,188 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+'use client';
+
+import type { ItemInstance } from '@headless-tree/core';
+import { ChevronDownIcon } from '@radix-ui/react-icons';
+import { Slot } from '@radix-ui/react-slot';
+import * as React from 'react';
+import { cn } from '../utils';
+
+interface TreeContextValue {
+ indent: number;
+ currentItem?: ItemInstance;
+ tree?: any;
+}
+
+const TreeContext = React.createContext({
+ indent: 20,
+ currentItem: undefined,
+ tree: undefined,
+});
+
+function useTreeContext() {
+ return React.useContext(TreeContext) as TreeContextValue;
+}
+
+interface TreeProps extends React.HTMLAttributes {
+ indent?: number;
+ tree?: any;
+}
+
+function Tree({ indent = 20, tree, className, ...props }: TreeProps) {
+ const containerProps =
+ tree && typeof tree.getContainerProps === 'function' ? tree.getContainerProps() : {};
+ const mergedProps = { ...props, ...containerProps };
+
+ // Extract style from mergedProps to merge with our custom styles
+ const { style: propStyle, ...otherProps } = mergedProps;
+
+ // Merge styles
+ const mergedStyle = {
+ ...propStyle,
+ '--tree-indent': `${indent}px`,
+ } as React.CSSProperties;
+
+ return (
+
+
+
+ );
+}
+
+interface TreeItemProps extends React.HTMLAttributes {
+ item: ItemInstance;
+ indent?: number;
+ asChild?: boolean;
+}
+
+function TreeItem({
+ item,
+ className,
+ asChild,
+ children,
+ ...props
+}: Omit, 'indent'>) {
+ const { indent } = useTreeContext();
+
+ const itemProps = typeof item.getProps === 'function' ? item.getProps() : {};
+ const mergedProps = { ...props, ...itemProps };
+
+ // Extract style from mergedProps to merge with our custom styles
+ const { style: propStyle, ...otherProps } = mergedProps;
+
+ // Merge styles
+ const mergedStyle = {
+ ...propStyle,
+ '--tree-padding': `${item.getItemMeta().level * indent}px`,
+ } as React.CSSProperties;
+
+ const Comp = asChild ? Slot : 'button';
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+interface TreeItemLabelProps extends React.HTMLAttributes {
+ item?: ItemInstance;
+}
+
+// eslint-disable-next-line sonarjs/cognitive-complexity
+function TreeItemLabel({
+ item: propItem,
+ children,
+ className,
+ ...props
+}: TreeItemLabelProps) {
+ const { currentItem } = useTreeContext();
+ const item = propItem || currentItem;
+
+ if (!item) {
+ console.warn('TreeItemLabel: No item provided via props or context');
+ return null;
+ }
+
+ const isFolder = typeof item.isFolder === 'function' ? item.isFolder() : false;
+ const isSelected = typeof item.isSelected === 'function' ? item.isSelected() : false;
+ const isDragTarget = typeof item.isDragTarget === 'function' ? item.isDragTarget() : false;
+ const isSearchMatch =
+ typeof item.isMatchingSearch === 'function' ? item.isMatchingSearch() : false;
+
+ return (
+
+ {item.isFolder() && (
+
+ )}
+ {children || (typeof item.getItemName === 'function' ? item.getItemName() : null)}
+
+ );
+}
+
+function TreeDragLine({ className, ...props }: React.HTMLAttributes) {
+ const { tree } = useTreeContext();
+
+ if (!tree || typeof tree.getDragLineStyle !== 'function') {
+ console.warn(
+ 'TreeDragLine: No tree provided via context or tree does not have getDragLineStyle method'
+ );
+ return null;
+ }
+
+ const dragLine = tree.getDragLineStyle();
+ return (
+
+ );
+}
+
+export { Tree, TreeItem, TreeItemLabel, TreeDragLine };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 61cef4b160..346ac4b222 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1799,6 +1799,12 @@ importers:
'@glideapps/glide-data-grid':
specifier: 6.0.3
version: 6.0.3(lodash@4.17.21)(marked@14.1.3)(react-dom@18.3.1(react@18.3.1))(react-responsive-carousel@3.2.23)(react@18.3.1)
+ '@headless-tree/core':
+ specifier: 1.5.1
+ version: 1.5.1
+ '@headless-tree/react':
+ specifier: 1.5.1
+ version: 1.5.1(@headless-tree/core@1.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-accordion':
specifier: ^1.1.2
version: 1.2.2(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4506,6 +4512,16 @@ packages:
'@hapi/topo@5.1.0':
resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==}
+ '@headless-tree/core@1.5.1':
+ resolution: {integrity: sha512-uPoFcjPYdnXwuEDJd2oCMY8a4nnsMKyx6P0G1+is6dIGFpUsoV0qjtJN6ykJtOgTHVhBRR11zRmletER6Qgj/Q==}
+
+ '@headless-tree/react@1.5.1':
+ resolution: {integrity: sha512-8r34ug5g25peTDgyGoCZf5Ohy4O0FMhdhUNyiaXzGn/1nwUDpmmwsrqh64DVNPereSA9uhY0s39uRibfvdmTqw==}
+ peerDependencies:
+ '@headless-tree/core': '*'
+ react: '*'
+ react-dom: '*'
+
'@hello-pangea/dnd@16.6.0':
resolution: {integrity: sha512-vfZ4GydqbtUPXSLfAvKvXQ6xwRzIjUSjVU0Sx+70VOhc2xx6CdmJXJ8YhH70RpbTUGjxctslQTHul9sIOxCfFQ==}
peerDependencies:
@@ -21350,6 +21366,14 @@ snapshots:
dependencies:
'@hapi/hoek': 9.3.0
+ '@headless-tree/core@1.5.1': {}
+
+ '@headless-tree/react@1.5.1(@headless-tree/core@1.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@headless-tree/core': 1.5.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
'@hello-pangea/dnd@16.6.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.26.0