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 ( <> 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