From f7c90e750e12d89cf5e11839099932c25bc427f9 Mon Sep 17 00:00:00 2001 From: caoxing Date: Thu, 5 Jun 2025 19:36:01 +0800 Subject: [PATCH] feat: support move table --- .../src/db-provider/db.provider.interface.ts | 3 + .../db-provider/move-table-query/abstract.ts | 16 + .../move-table-query.postgres.ts | 44 ++ .../move-table-query.sqlite.ts | 37 + .../src/db-provider/postgres.provider.ts | 6 + .../src/db-provider/sqlite.provider.ts | 6 + .../collaborator/collaborator.service.ts | 4 +- .../open-api/table-open-api.controller.ts | 15 + .../table/open-api/table-open-api.module.ts | 9 +- .../table/open-api/table-open-api.service.ts | 21 + .../src/features/table/table-move.service.ts | 656 ++++++++++++++++++ .../test/table-move.e2e-spec.ts | 364 ++++++++++ .../blocks/base/base-side-bar/BaseSideBar.tsx | 4 + .../app/blocks/base/base-side-bar/store.ts | 25 + .../app/blocks/table-list/MoveBasePanel.tsx | 262 +++++++ .../app/blocks/table-list/TableOperation.tsx | 14 + .../common-i18n/src/locales/en/table.json | 7 + .../common-i18n/src/locales/zh/table.json | 7 + packages/openapi/src/table/index.ts | 1 + packages/openapi/src/table/move.ts | 48 ++ 20 files changed, 1546 insertions(+), 3 deletions(-) create mode 100644 apps/nestjs-backend/src/db-provider/move-table-query/abstract.ts create mode 100644 apps/nestjs-backend/src/db-provider/move-table-query/move-table-query.postgres.ts create mode 100644 apps/nestjs-backend/src/db-provider/move-table-query/move-table-query.sqlite.ts create mode 100644 apps/nestjs-backend/src/features/table/table-move.service.ts create mode 100644 apps/nestjs-backend/test/table-move.e2e-spec.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/base/base-side-bar/store.ts create mode 100644 apps/nextjs-app/src/features/app/blocks/table-list/MoveBasePanel.tsx create mode 100644 packages/openapi/src/table/move.ts diff --git a/apps/nestjs-backend/src/db-provider/db.provider.interface.ts b/apps/nestjs-backend/src/db-provider/db.provider.interface.ts index 15197dc503..56011612fb 100644 --- a/apps/nestjs-backend/src/db-provider/db.provider.interface.ts +++ b/apps/nestjs-backend/src/db-provider/db.provider.interface.ts @@ -13,6 +13,7 @@ import type { IFilterQueryInterface } from './filter-query/filter-query.interfac import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface'; import type { IndexBuilderAbstract } from './index-query/index-abstract-builder'; import type { IntegrityQueryAbstract } from './integrity-query/abstract'; +import type { MoveTableQueryAbstract } from './move-table-query/abstract'; import type { ISortQueryInterface } from './sort-query/sort-query.interface'; export type IFilterQueryExtra = { @@ -62,6 +63,8 @@ export interface IDbProvider { value: string ): string; + moveTableQuery(queryBuilder: Knex.QueryBuilder): MoveTableQueryAbstract; + updateJsonArrayColumn( tableName: string, columnName: string, diff --git a/apps/nestjs-backend/src/db-provider/move-table-query/abstract.ts b/apps/nestjs-backend/src/db-provider/move-table-query/abstract.ts new file mode 100644 index 0000000000..bba4c24e3a --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/move-table-query/abstract.ts @@ -0,0 +1,16 @@ +import type { Knex } from 'knex'; + +export abstract class MoveTableQueryAbstract { + constructor(protected readonly queryBuilder: Knex.QueryBuilder) {} + + abstract getSourceBaseJunctionTableName(sourceBaseId: string): Knex.QueryBuilder; + + abstract getFullSourceBaseJunctionTableNames( + sourceBaseId: string, + nameFromSqlQuery: string[] + ): string[]; + + abstract getMovedDbTableName(dbTableName: string, targetSchema: string): string; + + abstract updateTableSchema(sourceDbTableName: string, targetSchema: string): Knex.QueryBuilder; +} diff --git a/apps/nestjs-backend/src/db-provider/move-table-query/move-table-query.postgres.ts b/apps/nestjs-backend/src/db-provider/move-table-query/move-table-query.postgres.ts new file mode 100644 index 0000000000..23b2ac1f72 --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/move-table-query/move-table-query.postgres.ts @@ -0,0 +1,44 @@ +import type { Knex } from 'knex'; +import { MoveTableQueryAbstract } from './abstract'; + +export class MoveTableQueryPostgres extends MoveTableQueryAbstract { + protected knex: Knex.Client; + constructor(queryBuilder: Knex.QueryBuilder) { + super(queryBuilder); + this.knex = queryBuilder.client; + } + + getSourceBaseJunctionTableName(sourceBaseId: string): Knex.QueryBuilder { + return this.queryBuilder + .select('table_name') + .from('information_schema.tables') + .where('table_schema', sourceBaseId) + .where('table_name', 'like', '%junction_%') + .where('table_type', 'BASE TABLE'); + } + + getFullSourceBaseJunctionTableNames(sourceBaseId: string, nameFromSqlQuery: string[]): string[] { + if (!Array.isArray(nameFromSqlQuery)) { + return []; + } + return nameFromSqlQuery.map((name) => { + return `${sourceBaseId}.${name}`; + }); + } + + getMovedDbTableName(dbTableName: string, targetSchema: string): string { + const [, tableName] = dbTableName.split('.'); + return `${targetSchema}.${tableName}`; + } + + updateTableSchema(sourceDbTableName: string, targetSchema: string): Knex.QueryBuilder { + const [schema, tableName] = sourceDbTableName.split('.'); + return this.knex.raw( + ` + ALTER TABLE ??.?? + SET SCHEMA ?? + `, + [schema, tableName, targetSchema] + ); + } +} diff --git a/apps/nestjs-backend/src/db-provider/move-table-query/move-table-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/move-table-query/move-table-query.sqlite.ts new file mode 100644 index 0000000000..806920a324 --- /dev/null +++ b/apps/nestjs-backend/src/db-provider/move-table-query/move-table-query.sqlite.ts @@ -0,0 +1,37 @@ +import type { Knex } from 'knex'; +import { MoveTableQueryAbstract } from './abstract'; + +export class MoveTableQuerySqlite extends MoveTableQueryAbstract { + protected knex: Knex.Client; + constructor(queryBuilder: Knex.QueryBuilder) { + super(queryBuilder); + this.knex = queryBuilder.client; + } + + getSourceBaseJunctionTableName(sourceBaseId: string): Knex.QueryBuilder { + return this.queryBuilder + .select('name as table_name') + .from('sqlite_master') + .where('type', 'table') + .where('name', 'like', `%${sourceBaseId}_junction%`); + } + + getFullSourceBaseJunctionTableNames(sourceBaseId: string, nameFromSqlQuery: string[]) { + if (!Array.isArray(nameFromSqlQuery)) { + return []; + } + return nameFromSqlQuery; + } + + getMovedDbTableName(dbTableName: string, targetSchema: string): string { + const schemaDelimiterIndex = dbTableName.indexOf('_'); + const tableName = dbTableName.slice(schemaDelimiterIndex + 1); + return `${targetSchema}_${tableName}`; + } + + updateTableSchema(dbTableName: string, targetSchema: string): Knex.QueryBuilder { + const newDbTableName = this.getMovedDbTableName(dbTableName, targetSchema); + + return this.knex.raw('ALTER TABLE ?? RENAME TO ??', [dbTableName, newDbTableName]); + } +} diff --git a/apps/nestjs-backend/src/db-provider/postgres.provider.ts b/apps/nestjs-backend/src/db-provider/postgres.provider.ts index 419efa9a76..4a61a35312 100644 --- a/apps/nestjs-backend/src/db-provider/postgres.provider.ts +++ b/apps/nestjs-backend/src/db-provider/postgres.provider.ts @@ -26,6 +26,8 @@ import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group import { GroupQueryPostgres } from './group-query/group-query.postgres'; import type { IntegrityQueryAbstract } from './integrity-query/abstract'; import { IntegrityQueryPostgres } from './integrity-query/integrity-query.postgres'; +import type { MoveTableQueryAbstract } from './move-table-query/abstract'; +import { MoveTableQueryPostgres } from './move-table-query/move-table-query.postgres'; import { SearchQueryAbstract } from './search-query/abstract'; import { IndexBuilderPostgres } from './search-query/search-index-builder.postgres'; import { @@ -48,6 +50,10 @@ export class PostgresProvider implements IDbProvider { ]; } + moveTableQuery(queryBuilder: Knex.QueryBuilder): MoveTableQueryAbstract { + return new MoveTableQueryPostgres(queryBuilder); + } + dropSchema(schemaName: string): string { return this.knex.raw(`DROP SCHEMA IF EXISTS ?? CASCADE`, [schemaName]).toQuery(); } diff --git a/apps/nestjs-backend/src/db-provider/sqlite.provider.ts b/apps/nestjs-backend/src/db-provider/sqlite.provider.ts index 8e1f2c338d..3598ae251b 100644 --- a/apps/nestjs-backend/src/db-provider/sqlite.provider.ts +++ b/apps/nestjs-backend/src/db-provider/sqlite.provider.ts @@ -26,6 +26,8 @@ import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group import { GroupQuerySqlite } from './group-query/group-query.sqlite'; import type { IntegrityQueryAbstract } from './integrity-query/abstract'; import { IntegrityQuerySqlite } from './integrity-query/integrity-query.sqlite'; +import type { MoveTableQueryAbstract } from './move-table-query/abstract'; +import { MoveTableQuerySqlite } from './move-table-query/move-table-query.sqlite'; import { SearchQueryAbstract } from './search-query/abstract'; import { getOffset } from './search-query/get-offset'; import { IndexBuilderSqlite } from './search-query/search-index-builder.sqlite'; @@ -44,6 +46,10 @@ export class SqliteProvider implements IDbProvider { return undefined; } + moveTableQuery(queryBuilder: Knex.QueryBuilder): MoveTableQueryAbstract { + return new MoveTableQuerySqlite(queryBuilder); + } + dropSchema(_schemaName: string) { return undefined; } diff --git a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts index dcdd531ca3..290a8883f9 100644 --- a/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts +++ b/apps/nestjs-backend/src/features/collaborator/collaborator.service.ts @@ -88,7 +88,7 @@ export class CollaboratorService { const query = this.knex .insert( collaborators.map((collaborator) => ({ - id: getRandomString(16), + id: getRandomString(25), resource_id: spaceId, resource_type: CollaboratorType.Space, role_name: role, @@ -659,7 +659,7 @@ export class CollaboratorService { const query = this.knex .insert( collaborators.map((collaborator) => ({ - id: getRandomString(16), + id: getRandomString(25), resource_id: baseId, resource_type: CollaboratorType.Base, role_name: role, diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts index dd9e1317d2..c5a8eaef4a 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.controller.ts @@ -25,9 +25,12 @@ import { TableIndex, duplicateTableRoSchema, IDuplicateTableRo, + moveTableRoSchema, + IMoveTableRo, } from '@teable/openapi'; import { ZodValidationPipe } from '../../../zod.validation.pipe'; import { Permissions } from '../../auth/decorators/permissions.decorator'; +import { ResourceMeta } from '../../auth/decorators/resource_meta.decorator'; import { TableIndexService } from '../table-index.service'; import { TablePermissionService } from '../table-permission.service'; import { TableService } from '../table.service'; @@ -143,6 +146,18 @@ export class TableController { return await this.tableOpenApiService.duplicateTable(baseId, tableId, duplicateTableRo); } + @Permissions('base|update') + @ResourceMeta('baseId', 'params') + @Post(':tableId/move') + async moveTable( + @Param('baseId') baseId: string, + @Param('tableId') tableId: string, + @Body(new ZodValidationPipe(moveTableRoSchema)) + moveTableRo: IMoveTableRo + ): Promise<{ baseId: string; tableId: string }> { + return await this.tableOpenApiService.moveTable(baseId, tableId, moveTableRo); + } + @Delete(':tableId') @Permissions('table|delete') async archiveTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) { diff --git a/apps/nestjs-backend/src/features/table/open-api/table-open-api.module.ts b/apps/nestjs-backend/src/features/table/open-api/table-open-api.module.ts index 1ccf11b5c3..68ab19c1f2 100644 --- a/apps/nestjs-backend/src/features/table/open-api/table-open-api.module.ts +++ b/apps/nestjs-backend/src/features/table/open-api/table-open-api.module.ts @@ -11,6 +11,7 @@ import { RecordModule } from '../../record/record.module'; import { ViewOpenApiModule } from '../../view/open-api/view-open-api.module'; import { TableDuplicateService } from '../table-duplicate.service'; import { TableIndexService } from '../table-index.service'; +import { TableMoveService } from '../table-move.service'; import { TableModule } from '../table.module'; import { TableController } from './table-open-api.controller'; import { TableOpenApiService } from './table-open-api.service'; @@ -29,7 +30,13 @@ import { TableOpenApiService } from './table-open-api.service'; GraphModule, ], controllers: [TableController], - providers: [DbProvider, TableOpenApiService, TableIndexService, TableDuplicateService], + providers: [ + DbProvider, + TableOpenApiService, + TableIndexService, + TableDuplicateService, + TableMoveService, + ], exports: [TableOpenApiService], }) export class TableOpenApiModule {} 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 d96295814f..23ed4fe88e 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 @@ -31,6 +31,7 @@ import type { ICreateTableRo, ICreateTableWithDefault, IDuplicateTableRo, + IMoveTableRo, ITableFullVo, ITablePermissionVo, ITableVo, @@ -39,9 +40,11 @@ import type { import { Knex } from 'knex'; import { nanoid } from 'nanoid'; import { InjectModel } from 'nest-knexjs'; +import { ClsService } from 'nestjs-cls'; import { ThresholdConfig, IThresholdConfig } from '../../../configs/threshold.config'; import { InjectDbProvider } from '../../../db-provider/db.provider'; import { IDbProvider } from '../../../db-provider/db.provider.interface'; +import type { IClsStore } from '../../../types/cls'; import { updateOrder } from '../../../utils/update-order'; import { PermissionService } from '../../auth/permission.service'; import { LinkService } from '../../calculation/link.service'; @@ -53,6 +56,7 @@ import { RecordOpenApiService } from '../../record/open-api/record-open-api.serv import { RecordService } from '../../record/record.service'; import { ViewOpenApiService } from '../../view/open-api/view-open-api.service'; import { TableDuplicateService } from '../table-duplicate.service'; +import { TableMoveService } from '../table-move.service'; import { TableService } from '../table.service'; @Injectable() @@ -70,6 +74,8 @@ export class TableOpenApiService { private readonly fieldSupplementService: FieldSupplementService, private readonly permissionService: PermissionService, private readonly tableDuplicateService: TableDuplicateService, + private readonly tableMoveService: TableMoveService, + private readonly cls: ClsService, @InjectDbProvider() private readonly dbProvider: IDbProvider, @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, @InjectModel('CUSTOM_KNEX') private readonly knex: Knex @@ -210,6 +216,21 @@ export class TableOpenApiService { return await this.tableDuplicateService.duplicateTable(baseId, tableId, tableRo); } + async moveTable(baseId: string, tableId: string, tableRo: IMoveTableRo) { + const { baseId: targetBaseId } = tableRo; + await this.checkBaseOwnerPermission(targetBaseId); + return await this.tableMoveService.moveTable(baseId, tableId, tableRo); + } + + private async checkBaseOwnerPermission(baseId: string) { + await this.permissionService.validPermissions(baseId, ['table|create']); + + const accessTokenId = this.cls.get('accessTokenId'); + if (accessTokenId) { + await this.permissionService.validPermissions(baseId, ['table|create'], accessTokenId); + } + } + async createTableMeta(baseId: string, tableRo: ICreateTableRo) { return await this.tableService.createTable(baseId, tableRo); } diff --git a/apps/nestjs-backend/src/features/table/table-move.service.ts b/apps/nestjs-backend/src/features/table/table-move.service.ts new file mode 100644 index 0000000000..242f135995 --- /dev/null +++ b/apps/nestjs-backend/src/features/table/table-move.service.ts @@ -0,0 +1,656 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import type { ILinkFieldOptions } from '@teable/core'; +import { FieldType, IdPrefix } from '@teable/core'; +import type { Field } from '@teable/db-main-prisma'; +import { PrismaService } from '@teable/db-main-prisma'; +import type { IMoveTableRo } from '@teable/openapi'; +import { Knex } from 'knex'; +import { differenceBy, isEmpty, omit } from 'lodash'; +import { InjectModel } from 'nest-knexjs'; +import { ClsService } from 'nestjs-cls'; +import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config'; +import { InjectDbProvider } from '../../db-provider/db.provider'; +import { IDbProvider } from '../../db-provider/db.provider.interface'; +import { RawOpType } from '../../share-db/interface'; +import type { IClsStore } from '../../types/cls'; +import { BatchService } from '../calculation/batch.service'; +import { FieldDuplicateService } from '../field/field-duplicate/field-duplicate.service'; +import { createFieldInstanceByRaw } from '../field/model/factory'; +import { FieldOpenApiService } from '../field/open-api/field-open-api.service'; +import { TableService } from './table.service'; + +@Injectable() +export class TableMoveService { + private logger = new Logger(TableMoveService.name); + + constructor( + private readonly cls: ClsService, + private readonly prismaService: PrismaService, + private readonly tableService: TableService, + private readonly fieldOpenService: FieldOpenApiService, + private readonly fieldDuplicateService: FieldDuplicateService, + private readonly batchService: BatchService, + @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig, + @InjectDbProvider() private readonly dbProvider: IDbProvider, + @InjectModel('CUSTOM_KNEX') private readonly knex: Knex + ) {} + + async moveTable(baseId: string, tableId: string, moveRo: IMoveTableRo) { + const { baseId: targetBaseId } = moveRo; + + const table = await this.prismaService.tableMeta.findUniqueOrThrow({ + where: { id: tableId }, + }); + + const maxOrder = await this.prismaService.tableMeta.aggregate({ + where: { baseId: targetBaseId, deletedTime: null }, + _max: { order: true }, + }); + + if (baseId === targetBaseId) { + throw new BadRequestException('Source baseId and target baseId are the same'); + } + + return this.prismaService.$tx( + async (prisma) => { + // move relative fields + await this.moveRelativeFields(baseId, targetBaseId, tableId); + + const newDbTableName = this.dbProvider + .moveTableQuery(this.knex.queryBuilder()) + .getMovedDbTableName(table.dbTableName, targetBaseId); + + // table meta + await prisma.tableMeta.update({ + where: { id: tableId }, + data: { + baseId: targetBaseId, + dbTableName: newDbTableName, + order: maxOrder._max.order ? maxOrder._max.order + 1 : 0, + version: table.version + 1, + }, + }); + + // move table plugins + await this.moveTablePlugins(baseId, targetBaseId, tableId); + + // move junction + await this.moveJunctionTable(baseId, targetBaseId, tableId); + + // move current base fields + await this.moveFields(baseId, targetBaseId, tableId); + + // move relative base fields + await this.moveRelativeFields(baseId, targetBaseId, tableId); + + // change table schema (move table to other base) + const sql = this.dbProvider + .moveTableQuery(this.knex.queryBuilder()) + .updateTableSchema(table.dbTableName, targetBaseId) + .toQuery(); + + await prisma.$executeRawUnsafe(sql); + + await this.moveRelativeTableConfig(table.dbTableName, newDbTableName); + + await this.batchService.saveRawOps(baseId, RawOpType.Del, IdPrefix.Table, [ + { docId: tableId, version: table.version }, + ]); + + await this.batchService.saveRawOps(targetBaseId, RawOpType.Create, IdPrefix.Table, [ + { docId: tableId, version: table.version }, + ]); + + return { + baseId: targetBaseId, + tableId, + }; + }, + { + timeout: 600000, + } + ); + } + + async moveRelativeFields(sourceBaseId: string, targetBaseId: string, tableId: string) { + const prisma = this.prismaService.txClient(); + + const allRelativeFields = await prisma.field.findMany({ + where: { + deletedTime: null, + NOT: { + tableId, + }, + OR: [ + { + lookupOptions: { + contains: `"foreignTableId":"${tableId}"`, + }, + }, + { + options: { + contains: `"foreignTableId":"${tableId}"`, + }, + }, + ], + }, + }); + + const tableIds = [...new Set(allRelativeFields.map(({ tableId }) => tableId))]; + + const baseInfo = await this.prismaService.tableMeta.findMany({ + where: { + deletedTime: null, + id: { + in: tableIds, + }, + }, + select: { + id: true, + baseId: true, + }, + }); + + const tableBaseIdMap = new Map(baseInfo.map(({ id, baseId }) => [id, baseId])); + + const sourceBaseRelativeFields = allRelativeFields.filter( + ({ tableId }) => tableBaseIdMap.get(tableId) === sourceBaseId + ); + + const targetBaseRelativeFields = allRelativeFields.filter( + ({ tableId }) => tableBaseIdMap.get(tableId) === targetBaseId + ); + + const otherBaseRelativeFields = allRelativeFields.filter( + ({ tableId }) => + ![...sourceBaseRelativeFields, ...targetBaseRelativeFields] + .map(({ tableId }) => tableId) + .includes(tableId) + ); + + await this.updateSourceBaseRelativeFields(targetBaseId, sourceBaseRelativeFields); + + await this.updateTargetBaseRelativeFields(sourceBaseId, targetBaseRelativeFields); + + await this.updateOtherBaseRelativeFields(sourceBaseId, targetBaseId, otherBaseRelativeFields); + } + + async updateSourceBaseRelativeFields(targetBaseId: string, fields: Field[]) { + const prisma = this.prismaService.txClient(); + + for (const field of fields) { + const fieldInstances = createFieldInstanceByRaw(field); + + const options = fieldInstances.options as ILinkFieldOptions; + + const newOptions = { + ...options, + }; + + if (field.type === FieldType.Link) { + newOptions.baseId = targetBaseId; + await prisma.field.update({ + where: { id: field.id }, + data: { + options: JSON.stringify(newOptions), + }, + }); + } + } + } + + async updateTargetBaseRelativeFields(sourceBaseId: string, fields: Field[]) { + const prisma = this.prismaService.txClient(); + + for (const field of fields) { + const fieldInstances = createFieldInstanceByRaw(field); + + const options = fieldInstances.options as ILinkFieldOptions; + + const newOptions = { + ...options, + }; + + if (field.type === FieldType.Link && newOptions.baseId === sourceBaseId) { + delete newOptions['baseId']; + + await prisma.field.update({ + where: { id: field.id }, + data: { + options: JSON.stringify(newOptions), + }, + }); + } + } + } + + async updateOtherBaseRelativeFields(sourceBaseId: string, targetBaseId: string, fields: Field[]) { + const prisma = this.prismaService.txClient(); + + for (const field of fields) { + const fieldInstances = createFieldInstanceByRaw(field); + + const options = fieldInstances.options as ILinkFieldOptions; + + const newOptions = { + ...options, + }; + + if (field.type === FieldType.Link && newOptions.baseId === sourceBaseId) { + newOptions['baseId'] = targetBaseId; + + await prisma.field.update({ + where: { id: field.id }, + data: { + options: JSON.stringify(newOptions), + }, + }); + } + } + } + + async moveFields(sourceBaseId: string, targetBaseId: string, tableId: string) { + const prisma = this.prismaService.txClient(); + + const fields = await prisma.field.findMany({ + where: { + deletedTime: null, + tableId, + }, + }); + + const linkFields = fields.filter((field) => field.type === FieldType.Link && !field.isLookup); + + const lookupFields = fields.filter((field) => field.isLookup); + + const rollupFields = fields.filter( + (field) => field.type === FieldType.Rollup && !field.isLookup + ); + + await this.moveLinkFields(sourceBaseId, targetBaseId, tableId, linkFields); + + await this.moveLookupOrRollupFields(sourceBaseId, targetBaseId, [ + ...lookupFields, + ...rollupFields, + ]); + } + + async moveLinkFields( + sourceBaseId: string, + targetBaseId: string, + tableId: string, + linkFields: Field[] + ) { + const prisma = this.prismaService.txClient(); + + const otherTables = await prisma.tableMeta.findMany({ + where: { + deletedTime: null, + baseId: sourceBaseId, + NOT: { + id: tableId, + }, + }, + select: { + id: true, + }, + }); + + const otherTableIds = otherTables.map(({ id }) => id); + + for (const field of linkFields) { + const fieldInstances = createFieldInstanceByRaw(field); + + const options = fieldInstances.options as ILinkFieldOptions; + + const newLinkOption = { + ...options, + }; + + if (isEmpty(newLinkOption)) { + continue; + } + + if (newLinkOption.fkHostTableName.startsWith(`${sourceBaseId}.`)) { + newLinkOption.fkHostTableName = newLinkOption.fkHostTableName.replace( + `${sourceBaseId}`, + `${targetBaseId}` + ); + } + + if (newLinkOption.baseId === targetBaseId) { + delete newLinkOption['baseId']; + } + + if (!newLinkOption.baseId && otherTableIds.includes(newLinkOption.foreignTableId)) { + newLinkOption.baseId = sourceBaseId; + } + + await prisma.field.update({ + where: { id: field.id }, + data: { + options: JSON.stringify(newLinkOption), + }, + }); + } + } + + async moveLookupOrRollupFields( + sourceBaseId: string, + targetBaseId: string, + lookupFields: Field[] + ) { + const prisma = this.prismaService.txClient(); + for (const field of lookupFields) { + const fieldInstances = createFieldInstanceByRaw(field); + + const lookupOptions = fieldInstances.lookupOptions as ILinkFieldOptions; + + const newLookupOption = { + ...lookupOptions, + }; + + if (isEmpty(newLookupOption)) { + continue; + } + + if (newLookupOption.fkHostTableName.startsWith(`${sourceBaseId}.`)) { + newLookupOption.fkHostTableName = newLookupOption.fkHostTableName.replace( + `${sourceBaseId}`, + `${targetBaseId}` + ); + } + + await prisma.field.update({ + where: { id: field.id }, + data: { + lookupOptions: JSON.stringify(newLookupOption), + }, + }); + } + } + + async moveJunctionTable(sourceBaseId: string, targetBaseId: string, tableId: string) { + const prisma = this.prismaService.txClient(); + const linkFieldRaws = await prisma.field.findMany({ + where: { + deletedTime: null, + isLookup: null, + tableId, + type: FieldType.Link, + }, + }); + + const linkFields = linkFieldRaws.map((fieldRaw) => createFieldInstanceByRaw(fieldRaw)); + + const junctionTableNames = linkFields + .filter((linkField) => { + const options = linkField.options as ILinkFieldOptions; + const { fkHostTableName } = options; + return fkHostTableName?.includes(`junction_`); + }) + .map((linkField) => { + const options = linkField.options as ILinkFieldOptions; + return options.fkHostTableName; + }); + + const junctionNameSql = this.dbProvider + .moveTableQuery(this.knex.queryBuilder()) + .getSourceBaseJunctionTableName(sourceBaseId) + .toQuery(); + + const allSourceBaseJunctionTableName = + // eslint-disable-next-line @typescript-eslint/naming-convention + await prisma.$queryRawUnsafe<{ table_name: string }[]>(junctionNameSql); + + const allFullSourceBaseJunctionTableNames = this.dbProvider + .moveTableQuery(this.knex.queryBuilder()) + .getFullSourceBaseJunctionTableNames( + sourceBaseId, + // eslint-disable-next-line @typescript-eslint/naming-convention + allSourceBaseJunctionTableName.map(({ table_name }) => table_name) + ); + + const shouldMoveJunctionTableNames = junctionTableNames.filter((junctionTableName) => + allFullSourceBaseJunctionTableNames.includes(junctionTableName) + ); + + for (const junctionTableName of shouldMoveJunctionTableNames) { + const sql = this.dbProvider + .moveTableQuery(this.knex.queryBuilder()) + .updateTableSchema(junctionTableName, targetBaseId) + .toQuery(); + + await prisma.$executeRawUnsafe(sql); + } + + await this.updateRelativeJunctionConfig( + sourceBaseId, + targetBaseId, + shouldMoveJunctionTableNames + ); + } + + async updateRelativeJunctionConfig( + sourceBaseId: string, + targetBaseId: string, + junctionTableNames: string[] + ) { + const prisma = this.prismaService.txClient(); + + const fieldRaws = await prisma.field.findMany({ + where: { + deletedTime: null, + OR: [ + ...junctionTableNames.map((junctionTableName) => ({ + options: { + contains: `"fkHostTableName":"${junctionTableName}"`, + }, + })), + ...junctionTableNames.map((junctionTableName) => ({ + lookupOptions: { + contains: `"fkHostTableName":"${junctionTableName}"`, + }, + })), + ], + }, + }); + + for (const field of fieldRaws) { + const fieldInstances = createFieldInstanceByRaw(field); + + const newOptions = fieldInstances.options as ILinkFieldOptions; + const newLookupOptions = fieldInstances.lookupOptions; + + if (newLookupOptions && junctionTableNames.includes(newLookupOptions.fkHostTableName)) { + newLookupOptions.fkHostTableName = newLookupOptions.fkHostTableName.replace( + `${sourceBaseId}`, + `${targetBaseId}` + ); + } + + if (newOptions && junctionTableNames.includes(newOptions.fkHostTableName)) { + newOptions.fkHostTableName = newOptions.fkHostTableName.replace( + `${sourceBaseId}`, + `${targetBaseId}` + ); + } + + await prisma.field.update({ + where: { id: field.id }, + data: { + options: JSON.stringify(newOptions), + lookupOptions: newLookupOptions ? JSON.stringify(newLookupOptions) : undefined, + }, + }); + } + } + + async moveRelativeTableConfig(sourceDbTableName: string, targetDbTableName: string) { + const prisma = this.prismaService.txClient(); + + const fieldRaws = await prisma.field.findMany({ + where: { + deletedTime: null, + OR: [ + { + options: { + contains: `"fkHostTableName":"${sourceDbTableName}"`, + }, + }, + { + lookupOptions: { + contains: `"fkHostTableName":"${sourceDbTableName}"`, + }, + }, + ], + }, + }); + + for (const field of fieldRaws) { + const fieldInstances = createFieldInstanceByRaw(field); + + const newOptions = fieldInstances.options as ILinkFieldOptions; + const newLookupOptions = fieldInstances.lookupOptions; + + if (newOptions && newOptions.fkHostTableName === sourceDbTableName) { + newOptions.fkHostTableName = targetDbTableName; + } + + if (newLookupOptions && newLookupOptions.fkHostTableName === sourceDbTableName) { + newLookupOptions.fkHostTableName = targetDbTableName; + } + + await prisma.field.update({ + where: { id: field.id }, + data: { + options: JSON.stringify(newOptions), + lookupOptions: newLookupOptions ? JSON.stringify(newLookupOptions) : undefined, + }, + }); + } + } + + async moveTablePlugins(sourceBaseId: string, targetBaseId: string, tableId: string) { + await this.movePluginPanel(targetBaseId, tableId); + await this.movePluginContextMenu(targetBaseId, tableId); + await this.movePluginCollaborator(sourceBaseId, targetBaseId); + } + + async movePluginPanel(targetBaseId: string, tableId: string) { + const prisma = this.prismaService.txClient(); + + const panelPlugins = await prisma.pluginPanel.findMany({ + where: { + tableId, + }, + select: { + id: true, + }, + }); + + const panelPluginIds = panelPlugins.map(({ id }) => id); + + await prisma.pluginInstall.updateMany({ + where: { + positionId: { + in: panelPluginIds, + }, + }, + data: { + baseId: targetBaseId, + }, + }); + } + + async movePluginContextMenu(targetBaseId: string, tableId: string) { + const prisma = this.prismaService.txClient(); + + const contextMenuPlugins = await prisma.pluginContextMenu.findMany({ + where: { + tableId, + }, + select: { + pluginInstallId: true, + }, + }); + + const pluginInstallIds = contextMenuPlugins.map(({ pluginInstallId }) => pluginInstallId); + + await prisma.pluginInstall.updateMany({ + where: { + id: { + in: pluginInstallIds, + }, + }, + data: { + baseId: targetBaseId, + }, + }); + } + + async movePluginCollaborator(sourceBaseId: string, targetBaseId: string) { + const prisma = this.prismaService.txClient(); + const userId = this.cls.get('user.id'); + + const pluginUsers = await prisma.plugin.findMany({ + where: { + NOT: { + pluginUser: null, + }, + }, + select: { + pluginUser: true, + }, + }); + + const pluginUserIds = pluginUsers + .map((item) => item.pluginUser) + .filter((userId): userId is string => userId !== null); + + if (pluginUserIds.length === 0) { + return; + } + + const sourcePluginCollaborators = await prisma.collaborator.findMany({ + where: { + resourceId: sourceBaseId, + principalId: { + in: pluginUserIds, + }, + }, + }); + + const targetPluginCollaborators = await prisma.collaborator.findMany({ + where: { + resourceId: targetBaseId, + principalId: { + in: pluginUserIds, + }, + }, + }); + + const diffCollaborators = differenceBy( + sourcePluginCollaborators, + targetPluginCollaborators, + 'principalId' + ).map((collaborator) => + omit(collaborator, [ + 'id', + 'createdTime', + 'createdBy', + 'lastModifiedBy', + 'lastModifiedTime', + 'resourceId', + ]) + ); + + await prisma.collaborator.createMany({ + data: diffCollaborators.map((collaborator) => ({ + ...collaborator, + resourceId: targetBaseId, + createdBy: userId, + })), + }); + } +} diff --git a/apps/nestjs-backend/test/table-move.e2e-spec.ts b/apps/nestjs-backend/test/table-move.e2e-spec.ts new file mode 100644 index 0000000000..bac93ce4b4 --- /dev/null +++ b/apps/nestjs-backend/test/table-move.e2e-spec.ts @@ -0,0 +1,364 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import type { INestApplication } from '@nestjs/common'; +import type { ILinkFieldOptions, ILookupOptionsVo } from '@teable/core'; +import { FieldType, Relationship } from '@teable/core'; +import type { ITableFullVo } from '@teable/openapi'; +import { + createBase, + createField, + createPluginPanel, + createTable, + deleteBase, + getRecords, + getTableList, + installPluginPanel, + moveTable, + updatePluginPanelStorage, +} from '@teable/openapi'; +import { x_20 } from './data-helpers/20x'; +import { x_20_link, x_20_link_from_lookups } from './data-helpers/20x-link'; +import { getFields, initApp } from './utils/init-app'; + +describe('Template Open API Controller (e2e)', () => { + let app: INestApplication; + const spaceId = globalThis.testConfig.spaceId; + let baseId1: string; + let baseId2: string; + let baseId3: string; + let base1Table: ITableFullVo; + let base1SubTable: ITableFullVo; + + beforeAll(async () => { + const appContext = await initApp(); + app = appContext.app; + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + const { id } = ( + await createBase({ + name: 'base-1', + spaceId, + }) + ).data; + baseId1 = id; + + baseId3 = ( + await createBase({ + name: 'base-3', + spaceId, + }) + ).data.id; + + base1Table = ( + await createTable(baseId1, { + // over 63 characters + name: 'record_query_long_long_long_long_long_long_long_long_long_long_long_long', + fields: x_20.fields, + records: x_20.records, + }) + ).data; + + const x20Link = x_20_link(base1Table); + base1SubTable = ( + await createTable(baseId1, { + name: 'lookup_filter_x_20', + fields: x20Link.fields, + records: x20Link.records, + }) + ).data; + + const x20LinkFromLookups = x_20_link_from_lookups(base1Table, base1SubTable.fields[2].id); + for (const field of x20LinkFromLookups.fields) { + await createField(base1SubTable.id, field); + } + + // create panel plugin + const panelPlugin = ( + await createPluginPanel(base1SubTable.id, { + name: 'panel-plugin', + }) + ).data; + + const panelInstalledPlugin1 = ( + await installPluginPanel(base1SubTable.id, panelPlugin.id, { + name: 'panel-plugin-1', + pluginId: 'plgchart', + }) + ).data; + + const textField = base1SubTable.fields.find((field) => field.type === FieldType.SingleLineText); + const numberField = base1SubTable.fields.find((field) => field.type === FieldType.Number); + + await updatePluginPanelStorage( + base1SubTable.id, + panelPlugin.id, + panelInstalledPlugin1.pluginInstallId, + { + storage: { + query: { + from: base1SubTable.id, + select: [ + { column: textField?.id, alias: textField?.name, type: 'field' }, + { column: numberField?.id, alias: numberField?.name, type: 'field' }, + ], + }, + config: { + type: 'bar', + xAxis: [{ column: textField?.name, display: { type: 'bar', position: 'auto' } }], + yAxis: [{ column: numberField?.name, display: { type: 'bar', position: 'auto' } }], + }, + }, + } + ); + + const { id: id2 } = ( + await createBase({ + name: 'base-2', + spaceId, + }) + ).data; + baseId2 = id2; + }); + + afterEach(async () => { + await deleteBase(baseId1); + await deleteBase(baseId2); + await deleteBase(baseId3); + }); + + it('should move a full type table to target base', async () => { + const beforeMoveFields = await getFields(base1SubTable.id); + const beforeMoveRecords = await getRecords(base1SubTable.id); + + const beforeMoveLinkSourceTableFields = await getFields(base1Table.id); + + await moveTable(baseId1, base1SubTable.id, { + baseId: baseId2, + }); + + const afterMoveFields = await getFields(base1SubTable.id); + + const afterMoveRecords = await getRecords(base1SubTable.id); + + const afterMoveLinkSourceTableFields = await getFields(base1Table.id); + + const sourceBaseTables = (await getTableList(baseId1)).data; + + const sourceBaseTableIds = sourceBaseTables + .map(({ id }) => id) + .filter((id) => id !== base1SubTable.id); + + const assertFields = beforeMoveFields.map((field) => { + const newField = { ...field }; + if (field.type === FieldType.Link) { + newField.options = { + ...newField.options, + fkHostTableName: (newField.options as ILinkFieldOptions).fkHostTableName.replace( + `${baseId1}`, + `${baseId2}` + ), + }; + + if (sourceBaseTableIds.includes((newField.options as ILinkFieldOptions).foreignTableId)) { + newField.options = { + ...newField.options, + baseId: baseId1, + }; + } + + if ((newField.options as ILinkFieldOptions)?.baseId === baseId2) { + delete (newField.options as ILinkFieldOptions).baseId; + } + } + + if (field.isLookup) { + newField.lookupOptions = { + ...newField.lookupOptions, + fkHostTableName: (newField.lookupOptions as ILookupOptionsVo).fkHostTableName.replace( + `${baseId1}`, + `${baseId2}` + ), + } as ILookupOptionsVo; + } + return newField; + }); + + expect(afterMoveFields).toEqual(assertFields); + expect(afterMoveRecords.data.records).toEqual(beforeMoveRecords.data.records); + + // test source base' other table which has link field with source table + const assertLinkSourceTableFields = beforeMoveLinkSourceTableFields.map((field) => { + const newField = { ...field }; + if (field.type === FieldType.Link) { + newField.options = { + ...newField.options, + fkHostTableName: (newField.options as ILinkFieldOptions).fkHostTableName.replace( + `${baseId1}`, + `${baseId2}` + ), + }; + + if ((newField.options as ILinkFieldOptions).foreignTableId === base1SubTable.id) { + (newField.options as ILinkFieldOptions).baseId = baseId2; + } + } + return newField; + }); + + expect(afterMoveLinkSourceTableFields).toEqual(assertLinkSourceTableFields); + }); + + it(`should move source table to target base which source base's table has link field with source table`, async () => { + const base2Table = ( + await createTable(baseId2, { + name: 'base2-table', + }) + ).data; + + const textField = base1SubTable.fields.find( + (field) => field.type === FieldType.SingleLineText + )!; + + const linkField = ( + await createField(base2Table.id, { + name: 'link-field', + type: FieldType.Link, + options: { + baseId: baseId1, + foreignTableId: base1SubTable.id, + relationship: Relationship.ManyMany, + }, + }) + ).data; + + await createField(base2Table.id, { + name: 'lookup-field', + isLookup: true, + options: {}, + lookupOptions: { + foreignTableId: base1SubTable.id, + linkFieldId: linkField.id, + lookupFieldId: textField.id, + }, + type: textField.type, + }); + + const beforeTargetBaseTablesFields = await getFields(base2Table.id); + + await moveTable(baseId1, base1SubTable.id, { + baseId: baseId2, + }); + + const afterTargetBaseTablesFields = await getFields(base2Table.id); + + const assertTargetBaseTablesFields = beforeTargetBaseTablesFields.map((field) => { + const newField = { ...field }; + if (field.type === FieldType.Link) { + newField.options = { + ...newField.options, + fkHostTableName: (newField.options as ILinkFieldOptions).fkHostTableName.replace( + `${baseId1}`, + `${baseId2}` + ), + }; + + if ((newField.options as ILinkFieldOptions).baseId === baseId1) { + delete (newField.options as ILinkFieldOptions).baseId; + } + } + + if (field.isLookup) { + newField.lookupOptions = { + ...newField.lookupOptions, + fkHostTableName: (newField.lookupOptions as ILookupOptionsVo).fkHostTableName.replace( + `${baseId1}`, + `${baseId2}` + ), + } as ILookupOptionsVo; + } + + return newField; + }); + + expect(afterTargetBaseTablesFields).toEqual(assertTargetBaseTablesFields); + }); + + it(`should move source table to target base which third base table link the source table's field`, async () => { + const base3Table = ( + await createTable(baseId3, { + name: 'base3-table', + }) + ).data; + + const textField = base1SubTable.fields.find( + (field) => field.type === FieldType.SingleLineText + )!; + + const linkField = ( + await createField(base3Table.id, { + name: 'link-field', + type: FieldType.Link, + options: { + baseId: baseId1, + foreignTableId: base1SubTable.id, + relationship: Relationship.ManyMany, + }, + }) + ).data; + + await createField(base3Table.id, { + name: 'lookup-field', + isLookup: true, + options: {}, + lookupOptions: { + foreignTableId: base1SubTable.id, + linkFieldId: linkField.id, + lookupFieldId: textField.id, + }, + type: textField.type, + }); + + const beforeTargetBaseTablesFields = await getFields(base3Table.id); + + await moveTable(baseId1, base1SubTable.id, { + baseId: baseId2, + }); + + const afterTargetBaseTablesFields = await getFields(base3Table.id); + + const assertTargetBaseTablesFields = beforeTargetBaseTablesFields.map((field) => { + const newField = { ...field }; + if (field.type === FieldType.Link) { + newField.options = { + ...newField.options, + fkHostTableName: (newField.options as ILinkFieldOptions).fkHostTableName.replace( + `${baseId1}`, + `${baseId2}` + ), + }; + + if ((newField.options as ILinkFieldOptions).baseId === baseId1) { + (newField.options as ILinkFieldOptions).baseId = baseId2; + } + } + + if (field.isLookup) { + newField.lookupOptions = { + ...newField.lookupOptions, + fkHostTableName: (newField.lookupOptions as ILookupOptionsVo).fkHostTableName.replace( + `${baseId1}`, + `${baseId2}` + ), + } as ILookupOptionsVo; + } + + return newField; + }); + + expect(afterTargetBaseTablesFields).toEqual(assertTargetBaseTablesFields); + }); +}); 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 b0b0aff9ea..0c741e5860 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 @@ -19,8 +19,10 @@ import { useTranslation } from 'next-i18next'; import { useMemo } from 'react'; import { useBaseUsage } from '@/features/app/hooks/useBaseUsage'; import { tableConfig } from '@/features/i18n/table.config'; +import { MoveBaseSelectPanel } from '../../table-list/MoveBasePanel'; import { TableList } from '../../table-list/TableList'; import { QuickAction } from './QuickAction'; +import { useBaseSideBarStore } from './store'; export const BaseSideBar = () => { const router = useRouter(); @@ -28,6 +30,7 @@ export const BaseSideBar = () => { const { t } = useTranslation(tableConfig.i18nNamespaces); const basePermission = useBasePermission(); const usage = useBaseUsage(); + const { moveBaseOpen, setMoveBaseOpen } = useBaseSideBarStore(); const { automationEnable = true, advancedPermissionsEnable = true } = usage?.limit ?? {}; @@ -164,6 +167,7 @@ export const BaseSideBar = () => { + ); }; diff --git a/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/store.ts b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/store.ts new file mode 100644 index 0000000000..cc8c95fd6a --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/base/base-side-bar/store.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +export interface IBaseSideBarStore { + moveBaseOpen: boolean; + setMoveBaseOpen: (open: boolean) => void; + selectTableId: string | null; + setSelectTableId: (tableId: string) => void; +} + +export const useBaseSideBarStore = create()((set, get) => ({ + moveBaseOpen: false, + selectTableId: null, + setMoveBaseOpen: (visible: boolean) => { + set({ + ...get(), + moveBaseOpen: visible, + }); + }, + setSelectTableId: (tableId: string | null) => { + set({ + ...get(), + selectTableId: tableId, + }); + }, +})); diff --git a/apps/nextjs-app/src/features/app/blocks/table-list/MoveBasePanel.tsx b/apps/nextjs-app/src/features/app/blocks/table-list/MoveBasePanel.tsx new file mode 100644 index 0000000000..bdc414a195 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/table-list/MoveBasePanel.tsx @@ -0,0 +1,262 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Check, Database } from '@teable/icons'; +import { getBaseAll, getSpaceList, getTableList, moveTable } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useBaseId } from '@teable/sdk/hooks'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Button, + Input, + cn, + DialogPortal, + DialogFooter, + Spin, +} from '@teable/ui-lib'; +import { groupBy, keyBy, mapValues } from 'lodash'; +import { useRouter } from 'next/router'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useBaseSideBarStore } from '../base/base-side-bar/store'; + +interface IBaseSelectPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const MoveBaseSelectPanel = (props: IBaseSelectPanelProps) => { + const { open, onOpenChange } = props; + const { t } = useTranslation(['common', 'table']); + const [selectedBaseId, setSelectedBaseId] = useState(null); + const [isMove, setIsMove] = useState(false); + const baseId = useBaseId(); + const router = useRouter(); + const [moveTipsOpen, setMoveTipsOpen] = useState(false); + const { selectTableId: tableId } = useBaseSideBarStore(); + + const queryClient = useQueryClient(); + + useEffect(() => { + if (!open) { + setIsMove(false); + setSelectedBaseId(null); + } + }, [open]); + + const { data: baseList = [] } = useQuery({ + queryKey: ReactQueryKeys.baseAll(), + queryFn: () => getBaseAll().then((data) => data.data), + }); + + const finalBaseList = baseList.filter((base) => base.id !== baseId); + + const { data: spaceList } = useQuery({ + queryKey: ReactQueryKeys.spaceList(), + queryFn: () => getSpaceList().then((data) => data.data), + }); + + const { data: tableList } = useQuery({ + queryKey: ReactQueryKeys.tableList(baseId!), + queryFn: () => getTableList(baseId!).then((data) => data.data), + enabled: !!baseId, + }); + + const { mutateAsync: moveTableFn, isLoading } = useMutation({ + mutationFn: () => moveTable(baseId as string, tableId!, { baseId: selectedBaseId! }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ReactQueryKeys.tableList(baseId as string), + }); + + setIsMove(true); + + if (tableList?.length) { + router.push( + { + pathname: `/base/[baseId]/[tableId]`, + query: { baseId, tableId: tableList[0].id }, + }, + undefined, + { + shallow: true, + } + ); + } else { + router.push( + { + pathname: `/base/[baseId]`, + query: { baseId }, + }, + undefined, + { + shallow: true, + } + ); + } + }, + }); + + const spaceId2NameMap = mapValues(keyBy(spaceList, 'id'), 'name'); + + const groupedBaseListMap = groupBy(finalBaseList, 'spaceId'); + + const groupedBaseList = Object.values( + mapValues(groupedBaseListMap, (bases, spaceId) => { + return { + spaceId: spaceId, + spaceName: spaceId2NameMap[spaceId], + bases: bases, + }; + }) + ); + + const [search, setSearch] = useState(''); + + const filteredGroupedBaseList = useMemo(() => { + return ( + groupedBaseList + .map((group) => { + const { bases } = group; + return { + ...group, + bases: search + ? bases.filter((base) => base.name.toLowerCase().includes(search.toLowerCase())) + : bases, + }; + }) + // the spaces has been deleted + .filter((group) => group.spaceName) + .filter((group) => group.bases.length > 0) + ); + }, [groupedBaseList, search]); + + return ( + <> + + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + {t('settings.templateAdmin.baseSelectPanel.title')} + + {t('settings.templateAdmin.baseSelectPanel.description')} + + + setSearch(e.target.value)} + className="h-8" + /> +
+ {filteredGroupedBaseList.length > 0 ? ( +
+ {filteredGroupedBaseList.map((group) => ( +
+
{group.spaceName}
+
+ {group.bases.map((base) => ( + + ))} +
+
+ ))} +
+ ) : ( +
+ {t('common:settings.templateAdmin.noData')} +
+ )} +
+ {!!filteredGroupedBaseList.length && ( + + + + + )} +
+
+
+ + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {t('table:table.moveTableTips.title')} +
+ {t('table:table.moveTableTips.tips')} +
+ + + + +
+
+ + ); +}; 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 d71eb9658e..c468b86536 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 @@ -10,6 +10,7 @@ import { FileCsv, FileExcel, Copy, + ArrowRight, } from '@teable/icons'; import { duplicateTable, PinType, SUPPORTEDTYPE } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; @@ -35,6 +36,7 @@ import { useTranslation } from 'next-i18next'; import React, { useMemo, useState } from 'react'; import { tableConfig } from '@/features/i18n/table.config'; import { useDownload } from '../../hooks/useDownLoad'; +import { useBaseSideBarStore } from '../base/base-side-bar/store'; import { TableImport } from '../import-table'; import { StarButton } from '../space/space-side-bar/StarButton'; @@ -58,6 +60,7 @@ export const TableOperation = (props: ITableOperationProps) => { const router = useRouter(); const queryClient = useQueryClient(); const { baseId, tableId: routerTableId } = router.query; + const { setSelectTableId, setMoveBaseOpen } = useBaseSideBarStore(); const { t } = useTranslation(tableConfig.i18nNamespaces); const { trigger } = useDownload({ downloadUrl: `/api/export/${table.id}`, key: 'table' }); @@ -169,6 +172,17 @@ export const TableOperation = (props: ITableOperationProps) => { {t('table:import.menu.duplicate')} )} + {menuPermission.duplicateTable && ( + { + setMoveBaseOpen(true); + setSelectTableId(table.id); + }} + > + + {t('table:import.menu.moveTo')} + + )} {menuPermission.exportTable && ( { diff --git a/packages/common-i18n/src/locales/en/table.json b/packages/common-i18n/src/locales/en/table.json index 95480f9e7b..f59ba08efa 100644 --- a/packages/common-i18n/src/locales/en/table.json +++ b/packages/common-i18n/src/locales/en/table.json @@ -355,6 +355,11 @@ "operator": { "createBlank": "Create a blank table" }, + "moveTableTips": { + "title": "Tips", + "tips": "Moving the table may affect automation operations (e.g. automation nodes configured for this table), which may cause failure. Do you still want to continue?", + "stillContinue": "Still continue" + }, "actionTips": { "copyAndPasteEnvironment": "Copy and paste only works in HTTPS or localhost", "copyAndPasteBrowser": "Copy and paste not supported in this browser", @@ -369,6 +374,7 @@ "deleting": "Deleting...", "deleteSuccessful": "Delete successful", "pasteFileFailed": "Files can only be pasted into an attachment field", + "moveTableSucceedAndJump": "move successful, click to jump", "copyError": { "noFocus": "Please keep the page active and do not switch windows" } @@ -426,6 +432,7 @@ "downAsCsv": "Download CSV", "importData": "Import Data", "duplicate": "Duplicate", + "moveTo": "Move to", "includeRecords": "Include records" }, "tips": { diff --git a/packages/common-i18n/src/locales/zh/table.json b/packages/common-i18n/src/locales/zh/table.json index 6a8cc9279f..f666ae8cac 100644 --- a/packages/common-i18n/src/locales/zh/table.json +++ b/packages/common-i18n/src/locales/zh/table.json @@ -354,6 +354,11 @@ "operator": { "createBlank": "创建一个新表格" }, + "moveTableTips": { + "title": "温馨提示", + "tips": "移动表格后,可能会影响自动化运行(如:自动化节点配置了此表),可能会执行失败,是否继续", + "stillContinue": "继续" + }, "actionTips": { "copyAndPasteEnvironment": "复制和粘贴功能仅在 HTTPS 或 localhost 环境下可用", "copyAndPasteBrowser": "当前浏览器不支持复制和粘贴功能", @@ -368,6 +373,7 @@ "deleting": "正在删除...", "deleteSuccessful": "删除成功", "pasteFileFailed": "文件只能粘贴到附件字段中", + "moveTableSucceedAndJump": "移动成功,点击跳转", "copyError": { "noFocus": "请保持页面激活,不要切换窗口" } @@ -425,6 +431,7 @@ "downAsCsv": "下载 CSV", "importData": "导入数据", "duplicate": "复制", + "moveTo": "移至", "importing": "导入中", "includeRecords": "包含记录" }, diff --git a/packages/openapi/src/table/index.ts b/packages/openapi/src/table/index.ts index 19f17107f4..c39ca4c032 100644 --- a/packages/openapi/src/table/index.ts +++ b/packages/openapi/src/table/index.ts @@ -15,3 +15,4 @@ export * from './get-activated-index'; export * from './get-abnormal-index'; export * from './repair-table-index'; export * from './duplicate'; +export * from './move'; diff --git a/packages/openapi/src/table/move.ts b/packages/openapi/src/table/move.ts new file mode 100644 index 0000000000..49a58c88a4 --- /dev/null +++ b/packages/openapi/src/table/move.ts @@ -0,0 +1,48 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import { tableVoSchema } from './create'; + +export const MOVE_TABLE = '/base/{baseId}/table/{tableId}/move'; + +export const moveTableRoSchema = z.object({ + baseId: z.string(), +}); + +export type IMoveTableRo = z.infer; + +export const MoveTableRoute: RouteConfig = registerRoute({ + method: 'post', + path: MOVE_TABLE, + summary: 'Get table details', + description: + 'Retrieve detailed information about a specific table, including its schema, name, and configuration.', + request: { + params: z.object({ + baseId: z.string(), + tableId: z.string(), + }), + }, + responses: { + 200: { + description: 'Returns data about a table.', + content: { + 'application/json': { + schema: tableVoSchema, + }, + }, + }, + }, + tags: ['table'], +}); + +export const moveTable = async (baseId: string, tableId: string, moveTableRo: IMoveTableRo) => { + return axios.post<{ baseId: string; tableId: string }>( + urlBuilder(MOVE_TABLE, { + baseId, + tableId, + }), + moveTableRo + ); +};