mirror of
https://github.com/teableio/teable.git
synced 2026-03-23 00:04:56 +08:00
feat: support duplicate table (#1335)
* feat: support duplicate table * fix: duplicate lookup field type unmatch * fix: duplicate table with record order crash * fix: update duplicate table auth * fix: unmatch column when duplicate table data * fix: duplicate table lose icon and desciption * chore: add duplicate table summary
This commit is contained in:
parent
8643f89924
commit
4fd2275bc1
@ -7,6 +7,7 @@ import type { DateFieldDto } from '../features/field/model/field-dto/date-field.
|
||||
import type { SchemaType } from '../features/field/util';
|
||||
import type { IAggregationQueryInterface } from './aggregation-query/aggregation-query.interface';
|
||||
import type { BaseQueryAbstract } from './base-query/abstract';
|
||||
import type { DuplicateTableQueryAbstract } from './duplicate-table/abstract';
|
||||
import type { IFilterQueryInterface } from './filter-query/filter-query.interface';
|
||||
import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';
|
||||
import type { IndexBuilderAbstract } from './index-query/index-abstract-builder';
|
||||
@ -159,6 +160,8 @@ export interface IDbProvider {
|
||||
|
||||
searchIndex(): IndexBuilderAbstract;
|
||||
|
||||
duplicateTableQuery(queryBuilder: Knex.QueryBuilder): DuplicateTableQueryAbstract;
|
||||
|
||||
shareFilterCollaboratorsQuery(
|
||||
originQueryBuilder: Knex.QueryBuilder,
|
||||
dbFieldName: string,
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export abstract class DuplicateTableQueryAbstract {
|
||||
constructor(protected readonly queryBuilder: Knex.QueryBuilder) {}
|
||||
|
||||
abstract duplicateTableData(
|
||||
sourceTable: string,
|
||||
targetTable: string,
|
||||
newColumns: string[],
|
||||
oldColumns: string[]
|
||||
): Knex.QueryBuilder;
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import type { Knex } from 'knex';
|
||||
import { DuplicateTableQueryAbstract } from './abstract';
|
||||
|
||||
export class DuplicateTableQueryPostgres extends DuplicateTableQueryAbstract {
|
||||
protected knex: Knex.Client;
|
||||
constructor(queryBuilder: Knex.QueryBuilder) {
|
||||
super(queryBuilder);
|
||||
this.knex = queryBuilder.client;
|
||||
}
|
||||
|
||||
duplicateTableData(
|
||||
sourceTable: string,
|
||||
targetTable: string,
|
||||
newColumns: string[],
|
||||
oldColumns: string[]
|
||||
) {
|
||||
const newColumnList = newColumns.map((col) => `"${col}"`).join(', ');
|
||||
const oldColumnList = oldColumns.map((col) => `"${col}"`).join(', ');
|
||||
return this.knex.raw(`INSERT INTO ?? (${newColumnList}) SELECT ${oldColumnList} FROM ??`, [
|
||||
targetTable,
|
||||
sourceTable,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import type { Knex } from 'knex';
|
||||
import { DuplicateTableQueryAbstract } from './abstract';
|
||||
|
||||
export class DuplicateTableQuerySqlite extends DuplicateTableQueryAbstract {
|
||||
protected knex: Knex.Client;
|
||||
constructor(queryBuilder: Knex.QueryBuilder) {
|
||||
super(queryBuilder);
|
||||
this.knex = queryBuilder.client;
|
||||
}
|
||||
|
||||
duplicateTableData(sourceTable: string, targetTable: string, columns: string[]) {
|
||||
const columnList = columns.map((col) => `"${col}"`).join(', ');
|
||||
return this.knex.raw(`INSERT INTO ?? (${columnList}) SELECT ${columnList} FROM ??`, [
|
||||
targetTable,
|
||||
sourceTable,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,7 @@ import type {
|
||||
IFilterQueryExtra,
|
||||
ISortQueryExtra,
|
||||
} from './db.provider.interface';
|
||||
import { DuplicateTableQueryPostgres } from './duplicate-table/duplicate-query.postgres';
|
||||
import type { IFilterQueryInterface } from './filter-query/filter-query.interface';
|
||||
import { FilterQueryPostgres } from './filter-query/postgres/filter-query.postgres';
|
||||
import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';
|
||||
@ -380,6 +381,10 @@ export class PostgresProvider implements IDbProvider {
|
||||
return new IndexBuilderPostgres();
|
||||
}
|
||||
|
||||
duplicateTableQuery(queryBuilder: Knex.QueryBuilder) {
|
||||
return new DuplicateTableQueryPostgres(queryBuilder);
|
||||
}
|
||||
|
||||
shareFilterCollaboratorsQuery(
|
||||
originQueryBuilder: Knex.QueryBuilder,
|
||||
dbFieldName: string,
|
||||
|
||||
@ -18,6 +18,7 @@ import type {
|
||||
IFilterQueryExtra,
|
||||
ISortQueryExtra,
|
||||
} from './db.provider.interface';
|
||||
import { DuplicateTableQuerySqlite } from './duplicate-table/duplicate-query.sqlite';
|
||||
import type { IFilterQueryInterface } from './filter-query/filter-query.interface';
|
||||
import { FilterQuerySqlite } from './filter-query/sqlite/filter-query.sqlite';
|
||||
import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';
|
||||
@ -337,6 +338,10 @@ export class SqliteProvider implements IDbProvider {
|
||||
return new IndexBuilderSqlite();
|
||||
}
|
||||
|
||||
duplicateTableQuery(queryBuilder: Knex.QueryBuilder) {
|
||||
return new DuplicateTableQuerySqlite(queryBuilder);
|
||||
}
|
||||
|
||||
shareFilterCollaboratorsQuery(
|
||||
originQueryBuilder: Knex.QueryBuilder,
|
||||
dbFieldName: string,
|
||||
|
||||
@ -139,7 +139,9 @@ export class FieldCalculationService {
|
||||
@Timing()
|
||||
async getRowCount(dbTableName: string) {
|
||||
const query = this.knex.count('*', { as: 'count' }).from(dbTableName).toQuery();
|
||||
const [{ count }] = await this.prismaService.$queryRawUnsafe<{ count: bigint }[]>(query);
|
||||
const [{ count }] = await this.prismaService
|
||||
.txClient()
|
||||
.$queryRawUnsafe<{ count: bigint }[]>(query);
|
||||
return Number(count);
|
||||
}
|
||||
|
||||
|
||||
@ -102,7 +102,7 @@ export class FieldSupplementService {
|
||||
}
|
||||
|
||||
private async getDefaultLinkName(foreignTableId: string) {
|
||||
const tableRaw = await this.prismaService.tableMeta.findUnique({
|
||||
const tableRaw = await this.prismaService.txClient().tableMeta.findUnique({
|
||||
where: { id: foreignTableId },
|
||||
select: { name: true },
|
||||
});
|
||||
@ -195,7 +195,7 @@ export class FieldSupplementService {
|
||||
const dbTableName = await this.getDbTableName(tableId);
|
||||
const foreignTableName = await this.getDbTableName(foreignTableId);
|
||||
|
||||
const { id: lookupFieldId } = await this.prismaService.field.findFirstOrThrow({
|
||||
const { id: lookupFieldId } = await this.prismaService.txClient().field.findFirstOrThrow({
|
||||
where: { tableId: foreignTableId, isPrimary: true },
|
||||
select: { id: true },
|
||||
});
|
||||
@ -374,7 +374,7 @@ export class FieldSupplementService {
|
||||
}
|
||||
|
||||
const { linkFieldId, lookupFieldId, foreignTableId } = lookupOptions;
|
||||
const linkFieldRaw = await this.prismaService.field.findFirst({
|
||||
const linkFieldRaw = await this.prismaService.txClient().field.findFirst({
|
||||
where: { id: linkFieldId, deletedTime: null, type: FieldType.Link },
|
||||
select: { name: true, options: true, isMultipleCellValue: true },
|
||||
});
|
||||
@ -392,7 +392,7 @@ export class FieldSupplementService {
|
||||
throw new BadRequestException(`foreignTableId ${foreignTableId} is invalid`);
|
||||
}
|
||||
|
||||
const lookupFieldRaw = await this.prismaService.field.findFirst({
|
||||
const lookupFieldRaw = await this.prismaService.txClient().field.findFirst({
|
||||
where: { id: lookupFieldId, deletedTime: null },
|
||||
});
|
||||
|
||||
@ -555,7 +555,7 @@ export class FieldSupplementService {
|
||||
throw new BadRequestException('expression parse error');
|
||||
}
|
||||
|
||||
const fieldRaws = await this.prismaService.field.findMany({
|
||||
const fieldRaws = await this.prismaService.txClient().field.findMany({
|
||||
where: { id: { in: fieldIds }, deletedTime: null },
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import type { IGetAbnormalVo, ITableFullVo, ITableListVo, ITableVo } from '@teable/openapi';
|
||||
import type {
|
||||
IDuplicateTableVo,
|
||||
IGetAbnormalVo,
|
||||
ITableFullVo,
|
||||
ITableListVo,
|
||||
ITableVo,
|
||||
} from '@teable/openapi';
|
||||
import {
|
||||
tableRoSchema,
|
||||
ICreateTableWithDefault,
|
||||
@ -17,6 +23,8 @@ import {
|
||||
IToggleIndexRo,
|
||||
toggleIndexRoSchema,
|
||||
TableIndex,
|
||||
duplicateTableRoSchema,
|
||||
IDuplicateTableRo,
|
||||
} from '@teable/openapi';
|
||||
import { ZodValidationPipe } from '../../../zod.validation.pipe';
|
||||
import { Permissions } from '../../auth/decorators/permissions.decorator';
|
||||
@ -123,6 +131,18 @@ export class TableController {
|
||||
return await this.tableOpenApiService.createTable(baseId, createTableRo);
|
||||
}
|
||||
|
||||
@Permissions('table|create')
|
||||
@Permissions('table|read')
|
||||
@Post(':tableId/duplicate')
|
||||
async duplicateTable(
|
||||
@Param('baseId') baseId: string,
|
||||
@Param('tableId') tableId: string,
|
||||
@Body(new ZodValidationPipe(duplicateTableRoSchema), TablePipe)
|
||||
duplicateTableRo: IDuplicateTableRo
|
||||
): Promise<IDuplicateTableVo> {
|
||||
return await this.tableOpenApiService.duplicateTable(baseId, tableId, duplicateTableRo);
|
||||
}
|
||||
|
||||
@Delete(':tableId')
|
||||
@Permissions('table|delete')
|
||||
async archiveTable(@Param('baseId') baseId: string, @Param('tableId') tableId: string) {
|
||||
|
||||
@ -8,6 +8,7 @@ import { GraphModule } from '../../graph/graph.module';
|
||||
import { RecordOpenApiModule } from '../../record/open-api/record-open-api.module';
|
||||
import { RecordModule } from '../../record/record.module';
|
||||
import { ViewOpenApiModule } from '../../view/open-api/view-open-api.module';
|
||||
import { TableDuplicateService } from '../table-dupicate.service';
|
||||
import { TableIndexService } from '../table-index.service';
|
||||
import { TableModule } from '../table.module';
|
||||
import { TableController } from './table-open-api.controller';
|
||||
@ -26,7 +27,7 @@ import { TableOpenApiService } from './table-open-api.service';
|
||||
GraphModule,
|
||||
],
|
||||
controllers: [TableController],
|
||||
providers: [DbProvider, TableOpenApiService, TableIndexService],
|
||||
providers: [DbProvider, TableOpenApiService, TableIndexService, TableDuplicateService],
|
||||
exports: [TableOpenApiService],
|
||||
})
|
||||
export class TableOpenApiModule {}
|
||||
|
||||
@ -30,6 +30,7 @@ import type {
|
||||
ICreateRecordsRo,
|
||||
ICreateTableRo,
|
||||
ICreateTableWithDefault,
|
||||
IDuplicateTableRo,
|
||||
ITableFullVo,
|
||||
ITablePermissionVo,
|
||||
ITableVo,
|
||||
@ -51,6 +52,7 @@ import { FieldOpenApiService } from '../../field/open-api/field-open-api.service
|
||||
import { RecordOpenApiService } from '../../record/open-api/record-open-api.service';
|
||||
import { RecordService } from '../../record/record.service';
|
||||
import { ViewOpenApiService } from '../../view/open-api/view-open-api.service';
|
||||
import { TableDuplicateService } from '../table-dupicate.service';
|
||||
import { TableService } from '../table.service';
|
||||
|
||||
@Injectable()
|
||||
@ -67,6 +69,7 @@ export class TableOpenApiService {
|
||||
private readonly fieldCreatingService: FieldCreatingService,
|
||||
private readonly fieldSupplementService: FieldSupplementService,
|
||||
private readonly permissionService: PermissionService,
|
||||
private readonly tableDuplicateService: TableDuplicateService,
|
||||
@InjectDbProvider() private readonly dbProvider: IDbProvider,
|
||||
@ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,
|
||||
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
|
||||
@ -196,6 +199,10 @@ export class TableOpenApiService {
|
||||
};
|
||||
}
|
||||
|
||||
async duplicateTable(baseId: string, tableId: string, tableRo: IDuplicateTableRo) {
|
||||
return await this.tableDuplicateService.duplicateTable(baseId, tableId, tableRo);
|
||||
}
|
||||
|
||||
async createTableMeta(baseId: string, tableRo: ICreateTableRo) {
|
||||
return await this.tableService.createTable(baseId, tableRo);
|
||||
}
|
||||
|
||||
879
apps/nestjs-backend/src/features/table/table-dupicate.service.ts
Normal file
879
apps/nestjs-backend/src/features/table/table-dupicate.service.ts
Normal file
@ -0,0 +1,879 @@
|
||||
import { BadGatewayException, Injectable, Logger } from '@nestjs/common';
|
||||
import type { IFormulaFieldOptions, ILinkFieldOptions, ILookupOptionsRo } from '@teable/core';
|
||||
import {
|
||||
generateViewId,
|
||||
generateShareId,
|
||||
FieldType,
|
||||
ViewType,
|
||||
generatePluginInstallId,
|
||||
} from '@teable/core';
|
||||
import type { View } from '@teable/db-main-prisma';
|
||||
import { PrismaService } from '@teable/db-main-prisma';
|
||||
import type { IDuplicateTableRo, IDuplicateTableVo } from '@teable/openapi';
|
||||
import { Knex } from 'knex';
|
||||
import { get, pick } 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 type { IClsStore } from '../../types/cls';
|
||||
import type { IFieldInstance } from '../field/model/factory';
|
||||
import { createFieldInstanceByRaw, rawField2FieldObj } from '../field/model/factory';
|
||||
import type { LinkFieldDto } from '../field/model/field-dto/link-field.dto';
|
||||
import { FieldOpenApiService } from '../field/open-api/field-open-api.service';
|
||||
import { ROW_ORDER_FIELD_PREFIX } from '../view/constant';
|
||||
import { createViewVoByRaw } from '../view/model/factory';
|
||||
import { ViewOpenApiService } from './../view/open-api/view-open-api.service';
|
||||
import { TableService } from './table.service';
|
||||
|
||||
@Injectable()
|
||||
export class TableDuplicateService {
|
||||
private logger = new Logger(TableDuplicateService.name);
|
||||
|
||||
constructor(
|
||||
private readonly cls: ClsService<IClsStore>,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly tableService: TableService,
|
||||
private readonly fieldOpenService: FieldOpenApiService,
|
||||
private readonly viewOpenService: ViewOpenApiService,
|
||||
@ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,
|
||||
@InjectDbProvider() private readonly dbProvider: IDbProvider,
|
||||
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
|
||||
) {}
|
||||
|
||||
async duplicateTable(baseId: string, tableId: string, duplicateRo: IDuplicateTableRo) {
|
||||
const { includeRecords, name } = duplicateRo;
|
||||
const {
|
||||
id: sourceTableId,
|
||||
icon,
|
||||
description,
|
||||
dbTableName,
|
||||
} = await this.prismaService.tableMeta.findUniqueOrThrow({
|
||||
where: { id: tableId },
|
||||
});
|
||||
return await this.prismaService.$tx(
|
||||
async () => {
|
||||
const newTableVo = await this.tableService.createTable(baseId, {
|
||||
name,
|
||||
icon,
|
||||
description,
|
||||
});
|
||||
const sourceToTargetFieldMap = await this.duplicateFields(sourceTableId, newTableVo.id);
|
||||
const sourceToTargetViewMap = await this.duplicateViews(
|
||||
sourceTableId,
|
||||
newTableVo.id,
|
||||
sourceToTargetFieldMap
|
||||
);
|
||||
await this.repairDuplicateOmit(
|
||||
sourceToTargetFieldMap,
|
||||
sourceToTargetViewMap,
|
||||
newTableVo.id
|
||||
);
|
||||
|
||||
includeRecords &&
|
||||
(await this.duplicateTableData(
|
||||
dbTableName,
|
||||
newTableVo.dbTableName,
|
||||
sourceToTargetViewMap,
|
||||
sourceToTargetFieldMap
|
||||
));
|
||||
|
||||
const viewPlain = await this.prismaService.txClient().view.findMany({
|
||||
where: {
|
||||
tableId: newTableVo.id,
|
||||
deletedTime: null,
|
||||
},
|
||||
});
|
||||
|
||||
const fieldPlain = await this.prismaService.txClient().field.findMany({
|
||||
where: {
|
||||
tableId: newTableVo.id,
|
||||
deletedTime: null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...newTableVo,
|
||||
views: viewPlain.map((v) => createViewVoByRaw(v)),
|
||||
fields: fieldPlain.map((f) => rawField2FieldObj(f)),
|
||||
viewMap: sourceToTargetViewMap,
|
||||
fieldMap: sourceToTargetFieldMap,
|
||||
} as IDuplicateTableVo;
|
||||
},
|
||||
{
|
||||
timeout: this.thresholdConfig.bigTransactionTimeout,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async duplicateTableData(
|
||||
sourceDbTableName: string,
|
||||
targetDbTableName: string,
|
||||
sourceToTargetViewMap: Record<string, string>,
|
||||
sourceToTargetFieldMap: Record<string, string>
|
||||
) {
|
||||
const prisma = this.prismaService.txClient();
|
||||
const qb = this.knex.queryBuilder();
|
||||
|
||||
const columnInfoQuery = this.dbProvider.columnInfo(sourceDbTableName);
|
||||
|
||||
const oldOriginColumns = (
|
||||
await this.prismaService.txClient().$queryRawUnsafe<{ name: string }[]>(columnInfoQuery)
|
||||
).map(({ name }) => name);
|
||||
|
||||
const oldFieldColumns = oldOriginColumns.filter(
|
||||
(name) => !name.startsWith(ROW_ORDER_FIELD_PREFIX) && !name.startsWith('__fk_fld')
|
||||
);
|
||||
|
||||
const oldRowColumns = oldOriginColumns.filter((name) =>
|
||||
name.startsWith(ROW_ORDER_FIELD_PREFIX)
|
||||
);
|
||||
|
||||
const oldFkColumns = oldOriginColumns.filter((name) => name.startsWith('__fk_fld'));
|
||||
|
||||
const newRowColumns = oldRowColumns.map((name) =>
|
||||
sourceToTargetViewMap[name.slice(6)] ? `__row_${sourceToTargetViewMap[name.slice(6)]}` : name
|
||||
);
|
||||
|
||||
const newFkColumns = oldFkColumns.map((name) =>
|
||||
sourceToTargetFieldMap[name.slice(5)] ? `__fk_${sourceToTargetFieldMap[name.slice(5)]}` : name
|
||||
);
|
||||
|
||||
for (const name of newRowColumns) {
|
||||
await this.createRowOrderField(targetDbTableName, name.slice(6));
|
||||
}
|
||||
|
||||
for (const name of newFkColumns) {
|
||||
await this.createFkField(targetDbTableName, name.slice(5));
|
||||
}
|
||||
|
||||
const oldColumns = oldFieldColumns.concat(oldRowColumns).concat(oldFkColumns);
|
||||
|
||||
const newColumns = oldFieldColumns.concat(newRowColumns).concat(newFkColumns);
|
||||
|
||||
const sql = this.dbProvider
|
||||
.duplicateTableQuery(qb)
|
||||
.duplicateTableData(sourceDbTableName, targetDbTableName, newColumns, oldColumns)
|
||||
.toQuery();
|
||||
|
||||
await prisma.$executeRawUnsafe(sql);
|
||||
}
|
||||
|
||||
private async createRowOrderField(dbTableName: string, viewId: string) {
|
||||
const prisma = this.prismaService.txClient();
|
||||
|
||||
const rowIndexFieldName = `${ROW_ORDER_FIELD_PREFIX}_${viewId}`;
|
||||
|
||||
const columnExists = await this.dbProvider.checkColumnExist(
|
||||
dbTableName,
|
||||
rowIndexFieldName,
|
||||
prisma
|
||||
);
|
||||
|
||||
if (!columnExists) {
|
||||
// add a field for maintain row order number
|
||||
const addRowIndexColumnSql = this.knex.schema
|
||||
.alterTable(dbTableName, (table) => {
|
||||
table.double(rowIndexFieldName);
|
||||
})
|
||||
.toQuery();
|
||||
await prisma.$executeRawUnsafe(addRowIndexColumnSql);
|
||||
}
|
||||
|
||||
// create index
|
||||
const indexName = `idx_${ROW_ORDER_FIELD_PREFIX}_${viewId}`;
|
||||
const createRowIndexSQL = this.knex
|
||||
.raw(
|
||||
`
|
||||
CREATE INDEX IF NOT EXISTS ?? ON ?? (??)
|
||||
`,
|
||||
[indexName, dbTableName, rowIndexFieldName]
|
||||
)
|
||||
.toQuery();
|
||||
|
||||
await prisma.$executeRawUnsafe(createRowIndexSQL);
|
||||
}
|
||||
|
||||
private async createFkField(dbTableName: string, fieldId: string) {
|
||||
const prisma = this.prismaService.txClient();
|
||||
|
||||
const fkFieldName = `__fk_${fieldId}`;
|
||||
|
||||
const columnExists = await this.dbProvider.checkColumnExist(dbTableName, fkFieldName, prisma);
|
||||
|
||||
if (!columnExists) {
|
||||
const addFkColumnSql = this.knex.schema
|
||||
.alterTable(dbTableName, (table) => {
|
||||
table.string(fkFieldName);
|
||||
})
|
||||
.toQuery();
|
||||
await prisma.$executeRawUnsafe(addFkColumnSql);
|
||||
}
|
||||
}
|
||||
|
||||
private async duplicateFields(sourceTableId: string, targetTableId: string) {
|
||||
const fieldsRaw = await this.prismaService.txClient().field.findMany({
|
||||
where: { tableId: sourceTableId, deletedTime: null },
|
||||
});
|
||||
const fieldsInstances = fieldsRaw.map((f) => createFieldInstanceByRaw(f));
|
||||
const sourceToTargetFieldMap: Record<string, string> = {};
|
||||
|
||||
const commonFields = fieldsInstances.filter(
|
||||
(f) => !f.isLookup && ![FieldType.Formula, FieldType.Link].includes(f.type as FieldType)
|
||||
);
|
||||
|
||||
for (let i = 0; i < commonFields.length; i++) {
|
||||
const { type, dbFieldName, name, options, isPrimary, id, unique, notNull, description } =
|
||||
commonFields[i];
|
||||
const newField = await this.fieldOpenService.createField(targetTableId, {
|
||||
type,
|
||||
dbFieldName: dbFieldName,
|
||||
name,
|
||||
options,
|
||||
description,
|
||||
});
|
||||
if (isPrimary || unique || notNull) {
|
||||
const updateData: {
|
||||
isPrimary?: boolean;
|
||||
unique?: boolean;
|
||||
notNull?: boolean;
|
||||
} = {
|
||||
isPrimary,
|
||||
};
|
||||
if (unique !== undefined) updateData.unique = unique;
|
||||
if (notNull !== undefined) updateData.notNull = notNull;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.prismaService.txClient().field.update({
|
||||
where: {
|
||||
id: newField?.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
}
|
||||
sourceToTargetFieldMap[id] = newField.id;
|
||||
}
|
||||
|
||||
// these field require other field, we need to merge them and ensure a specific order
|
||||
const linkFields = fieldsInstances.filter((f) => f.type === FieldType.Link && !f.isLookup);
|
||||
|
||||
await this.duplicateLinkFields(
|
||||
sourceTableId,
|
||||
targetTableId,
|
||||
linkFields,
|
||||
sourceToTargetFieldMap
|
||||
);
|
||||
|
||||
await this.duplicateDependFields(
|
||||
sourceTableId,
|
||||
targetTableId,
|
||||
fieldsInstances,
|
||||
sourceToTargetFieldMap
|
||||
);
|
||||
|
||||
return sourceToTargetFieldMap;
|
||||
}
|
||||
|
||||
// field could not set constraint when create
|
||||
private async replenishmentConstraint(
|
||||
fId: string,
|
||||
targetTableId: string,
|
||||
{ notNull, unique, dbFieldName }: { notNull?: boolean; unique?: boolean; dbFieldName: string }
|
||||
) {
|
||||
if (notNull || unique) {
|
||||
const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({
|
||||
where: {
|
||||
id: targetTableId,
|
||||
deletedTime: null,
|
||||
},
|
||||
select: {
|
||||
dbTableName: true,
|
||||
},
|
||||
});
|
||||
await this.prismaService.txClient().field.update({
|
||||
where: {
|
||||
id: fId,
|
||||
},
|
||||
data: {
|
||||
notNull,
|
||||
unique,
|
||||
},
|
||||
});
|
||||
|
||||
const fieldValidationQuery = this.knex.schema
|
||||
.alterTable(dbTableName, (table) => {
|
||||
if (unique) table.dropUnique([dbFieldName]);
|
||||
if (notNull) table.setNullable(dbFieldName);
|
||||
})
|
||||
.toQuery();
|
||||
|
||||
await this.prismaService.txClient().$executeRawUnsafe(fieldValidationQuery);
|
||||
}
|
||||
}
|
||||
|
||||
private async duplicateLinkFields(
|
||||
sourceTableId: string,
|
||||
targetTableId: string,
|
||||
linkFields: IFieldInstance[],
|
||||
sourceToTargetFieldMap: Record<string, string>
|
||||
) {
|
||||
const twoWaySelfLinkFields = linkFields.filter((f) => {
|
||||
const options = f.options as ILinkFieldOptions;
|
||||
return options.foreignTableId === sourceTableId;
|
||||
});
|
||||
|
||||
const mergedTwoWaySelfLinkFields = [] as [IFieldInstance, IFieldInstance][];
|
||||
|
||||
twoWaySelfLinkFields.forEach((f) => {
|
||||
// two-way self link field should only create one of it
|
||||
if (!mergedTwoWaySelfLinkFields.some((group) => group.some(({ id: fId }) => fId === f.id))) {
|
||||
const groupField = twoWaySelfLinkFields.find(
|
||||
({ options }) => get(options, 'symmetricFieldId') === f.id
|
||||
);
|
||||
groupField && mergedTwoWaySelfLinkFields.push([f, groupField]);
|
||||
}
|
||||
});
|
||||
|
||||
const otherLinkFields = linkFields.filter(
|
||||
(f) => !twoWaySelfLinkFields.map((f) => f.id).includes(f.id)
|
||||
);
|
||||
|
||||
// self link field
|
||||
for (let i = 0; i < mergedTwoWaySelfLinkFields.length; i++) {
|
||||
const f = mergedTwoWaySelfLinkFields[i][0];
|
||||
const { notNull, unique, description } = f;
|
||||
const groupField = mergedTwoWaySelfLinkFields[i][1] as LinkFieldDto;
|
||||
const { name, type, dbFieldName, id } = f;
|
||||
const options = f.options as ILinkFieldOptions;
|
||||
const newField = await this.fieldOpenService.createField(targetTableId, {
|
||||
type: type as FieldType,
|
||||
dbFieldName,
|
||||
name,
|
||||
description,
|
||||
options: {
|
||||
...pick(options, [
|
||||
'relationship',
|
||||
'isOneWay',
|
||||
'filterByViewId',
|
||||
'filter',
|
||||
'visibleFieldIds',
|
||||
]),
|
||||
foreignTableId: targetTableId,
|
||||
},
|
||||
});
|
||||
await this.replenishmentConstraint(newField.id, targetTableId, {
|
||||
notNull,
|
||||
unique,
|
||||
dbFieldName,
|
||||
});
|
||||
if (notNull || unique) {
|
||||
await this.prismaService.txClient().field.update({
|
||||
where: {
|
||||
id: newField?.id,
|
||||
},
|
||||
data: {
|
||||
unique,
|
||||
notNull,
|
||||
},
|
||||
});
|
||||
}
|
||||
sourceToTargetFieldMap[id] = newField.id;
|
||||
sourceToTargetFieldMap[options.symmetricFieldId!] = (
|
||||
newField.options as ILinkFieldOptions
|
||||
).symmetricFieldId!;
|
||||
|
||||
// self link should updated the opposite field dbFieldName and name
|
||||
const { dbTableName: targetDbTableName } = await this.prismaService
|
||||
.txClient()
|
||||
.tableMeta.findUniqueOrThrow({
|
||||
where: {
|
||||
id: targetTableId,
|
||||
},
|
||||
select: {
|
||||
dbTableName: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { dbFieldName: genDbFieldName } = await this.prismaService
|
||||
.txClient()
|
||||
.field.findUniqueOrThrow({
|
||||
where: {
|
||||
id: sourceToTargetFieldMap[groupField.id],
|
||||
},
|
||||
select: {
|
||||
dbFieldName: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prismaService.txClient().field.update({
|
||||
where: {
|
||||
id: sourceToTargetFieldMap[groupField.id],
|
||||
},
|
||||
data: {
|
||||
dbFieldName: groupField.dbFieldName,
|
||||
name: groupField.name,
|
||||
options: JSON.stringify({ ...groupField.options, foreignTableId: targetTableId }),
|
||||
},
|
||||
});
|
||||
|
||||
const alterTableSql = this.dbProvider.renameColumn(
|
||||
targetDbTableName,
|
||||
genDbFieldName,
|
||||
groupField.dbFieldName
|
||||
);
|
||||
|
||||
for (const sql of alterTableSql) {
|
||||
await this.prismaService.txClient().$executeRawUnsafe(sql);
|
||||
}
|
||||
}
|
||||
|
||||
// other common link field
|
||||
for (let i = 0; i < otherLinkFields.length; i++) {
|
||||
const f = otherLinkFields[i];
|
||||
const { type, description, name, notNull, unique, options, dbFieldName } = f;
|
||||
const newField = await this.fieldOpenService.createField(targetTableId, {
|
||||
type: type as FieldType,
|
||||
description,
|
||||
dbFieldName,
|
||||
name,
|
||||
options: {
|
||||
...pick(options, [
|
||||
'relationship',
|
||||
'foreignTableId',
|
||||
'isOneWay',
|
||||
'filterByViewId',
|
||||
'filter',
|
||||
'visibleFieldIds',
|
||||
]),
|
||||
},
|
||||
});
|
||||
await this.replenishmentConstraint(newField.id, targetTableId, {
|
||||
notNull,
|
||||
unique,
|
||||
dbFieldName,
|
||||
});
|
||||
sourceToTargetFieldMap[f.id] = newField.id;
|
||||
}
|
||||
}
|
||||
|
||||
private async duplicateDependFields(
|
||||
sourceTableId: string,
|
||||
targetTableId: string,
|
||||
fieldsInstances: IFieldInstance[],
|
||||
sourceToTargetFieldMap: Record<string, string>
|
||||
) {
|
||||
const dependFields = fieldsInstances.filter((f) => f.isLookup || f.type === FieldType.Formula);
|
||||
if (!dependFields.length) return;
|
||||
|
||||
const checkedField = [] as IFieldInstance[];
|
||||
|
||||
while (dependFields.length) {
|
||||
const curField = dependFields.shift();
|
||||
if (!curField) continue;
|
||||
|
||||
const isChecked = checkedField.some((f) => f.id === curField.id);
|
||||
// InDegree all ready
|
||||
const isInDegreeReady = this.isInDegreeReady(curField, sourceTableId, sourceToTargetFieldMap);
|
||||
|
||||
if (isInDegreeReady) {
|
||||
await this.duplicateSingleDependField(
|
||||
sourceTableId,
|
||||
targetTableId,
|
||||
curField,
|
||||
sourceToTargetFieldMap
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isChecked) {
|
||||
if (curField.hasError) {
|
||||
await this.duplicateSingleDependField(
|
||||
sourceTableId,
|
||||
targetTableId,
|
||||
curField,
|
||||
sourceToTargetFieldMap,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
throw new BadGatewayException('Create circular field');
|
||||
}
|
||||
} else {
|
||||
dependFields.push(curField);
|
||||
checkedField.push(curField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isInDegreeReady(
|
||||
field: IFieldInstance,
|
||||
sourceTableId: string,
|
||||
sourceToTargetFieldMap: Record<string, string>
|
||||
) {
|
||||
if (field.type === FieldType.Formula) {
|
||||
const formulaOptions = field.options as IFormulaFieldOptions;
|
||||
const referencedFields = this.extractFieldIds(formulaOptions.expression);
|
||||
const keys = Object.keys(sourceToTargetFieldMap);
|
||||
return referencedFields.every((field) => keys.includes(field));
|
||||
}
|
||||
|
||||
if (field.isLookup) {
|
||||
const { lookupOptions } = field;
|
||||
const { foreignTableId, linkFieldId, lookupFieldId } = lookupOptions as ILookupOptionsRo;
|
||||
const isSelfLink = foreignTableId === sourceTableId;
|
||||
return isSelfLink
|
||||
? Boolean(sourceToTargetFieldMap[lookupFieldId] && sourceToTargetFieldMap[linkFieldId])
|
||||
: true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async duplicateSingleDependField(
|
||||
sourceTableId: string,
|
||||
targetTableId: string,
|
||||
field: IFieldInstance,
|
||||
sourceToTargetFieldMap: Record<string, string>,
|
||||
hasError = false
|
||||
) {
|
||||
if (field.type === FieldType.Formula) {
|
||||
await this.duplicateFormulaField(targetTableId, field, sourceToTargetFieldMap, hasError);
|
||||
} else if (field.isLookup) {
|
||||
await this.duplicateLookupField(sourceTableId, targetTableId, field, sourceToTargetFieldMap);
|
||||
}
|
||||
}
|
||||
|
||||
private async duplicateLookupField(
|
||||
sourceTableId: string,
|
||||
targetTableId: string,
|
||||
fieldInstance: IFieldInstance,
|
||||
sourceToTargetFieldMap: Record<string, string>
|
||||
) {
|
||||
const {
|
||||
dbFieldName,
|
||||
name,
|
||||
lookupOptions,
|
||||
id,
|
||||
hasError,
|
||||
options,
|
||||
notNull,
|
||||
unique,
|
||||
description,
|
||||
} = fieldInstance;
|
||||
const { foreignTableId, linkFieldId, lookupFieldId } = lookupOptions as ILookupOptionsRo;
|
||||
const isSelfLink = foreignTableId === sourceTableId;
|
||||
|
||||
const { type: lookupFieldType } = await this.prismaService.txClient().field.findUniqueOrThrow({
|
||||
where: {
|
||||
id: lookupFieldId,
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
const mockFieldId = Object.values(sourceToTargetFieldMap)[0];
|
||||
const { type: mockType } = await this.prismaService.txClient().field.findUniqueOrThrow({
|
||||
where: {
|
||||
id: mockFieldId,
|
||||
deletedTime: null,
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
const newField = await this.fieldOpenService.createField(targetTableId, {
|
||||
type: (hasError ? mockType : lookupFieldType) as FieldType,
|
||||
dbFieldName,
|
||||
description,
|
||||
isLookup: true,
|
||||
lookupOptions: {
|
||||
foreignTableId: isSelfLink ? targetTableId : foreignTableId,
|
||||
linkFieldId: isSelfLink ? sourceToTargetFieldMap[linkFieldId] : linkFieldId,
|
||||
lookupFieldId: isSelfLink
|
||||
? hasError
|
||||
? mockFieldId
|
||||
: sourceToTargetFieldMap[lookupFieldId]
|
||||
: hasError
|
||||
? mockFieldId
|
||||
: lookupFieldId,
|
||||
},
|
||||
name,
|
||||
});
|
||||
await this.replenishmentConstraint(newField.id, targetTableId, {
|
||||
notNull,
|
||||
unique,
|
||||
dbFieldName,
|
||||
});
|
||||
sourceToTargetFieldMap[id] = newField.id;
|
||||
if (hasError) {
|
||||
await this.prismaService.txClient().field.update({
|
||||
where: {
|
||||
id: newField.id,
|
||||
},
|
||||
data: {
|
||||
hasError,
|
||||
type: lookupFieldType,
|
||||
lookupOptions: JSON.stringify({
|
||||
...newField.lookupOptions,
|
||||
lookupFieldId: lookupFieldId,
|
||||
}),
|
||||
options: JSON.stringify(options),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async duplicateFormulaField(
|
||||
targetTableId: string,
|
||||
fieldInstance: IFieldInstance,
|
||||
sourceToTargetFieldMap: Record<string, string>,
|
||||
hasError: boolean = false
|
||||
) {
|
||||
const { type, dbFieldName, name, options, id, notNull, unique, description } = fieldInstance;
|
||||
const { expression } = options as IFormulaFieldOptions;
|
||||
let newExpression = expression;
|
||||
Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {
|
||||
newExpression = newExpression.replaceAll(key, value);
|
||||
});
|
||||
const mockFieldId = Object.values(sourceToTargetFieldMap)[0];
|
||||
const newField = await this.fieldOpenService.createField(targetTableId, {
|
||||
type,
|
||||
dbFieldName: dbFieldName,
|
||||
description,
|
||||
options: {
|
||||
...options,
|
||||
expression: hasError ? `{${mockFieldId}}` : newExpression,
|
||||
},
|
||||
name,
|
||||
});
|
||||
await this.replenishmentConstraint(newField.id, targetTableId, {
|
||||
notNull,
|
||||
unique,
|
||||
dbFieldName,
|
||||
});
|
||||
sourceToTargetFieldMap[id] = newField.id;
|
||||
|
||||
if (hasError) {
|
||||
await this.prismaService.txClient().field.update({
|
||||
where: {
|
||||
id: newField.id,
|
||||
},
|
||||
data: {
|
||||
hasError,
|
||||
options: JSON.stringify({
|
||||
...options,
|
||||
expression: newExpression,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async duplicateViews(
|
||||
sourceTableId: string,
|
||||
targetTableId: string,
|
||||
sourceToTargetFieldMap: Record<string, string>
|
||||
) {
|
||||
const views = await this.prismaService.view.findMany({
|
||||
where: { tableId: sourceTableId, deletedTime: null },
|
||||
});
|
||||
const viewsWithoutPlugin = views.filter((v) => v.type !== ViewType.Plugin);
|
||||
const pluginViews = views.filter(({ type }) => type === ViewType.Plugin);
|
||||
const sourceToTargetViewMap = {} as Record<string, string>;
|
||||
const userId = this.cls.get('user.id');
|
||||
const prisma = this.prismaService.txClient();
|
||||
await prisma.view.createMany({
|
||||
data: viewsWithoutPlugin.map((view) => {
|
||||
const fieldsToReplace = ['columnMeta', 'options', 'sort', 'group', 'filter'] as const;
|
||||
|
||||
const updatedFields = fieldsToReplace.reduce(
|
||||
(acc, field) => {
|
||||
if (view[field]) {
|
||||
acc[field] = Object.entries(sourceToTargetFieldMap).reduce(
|
||||
(result, [key, value]) => result.replaceAll(key, value),
|
||||
view[field]!
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<typeof view>
|
||||
);
|
||||
|
||||
const newViewId = generateViewId();
|
||||
|
||||
sourceToTargetViewMap[view.id] = newViewId;
|
||||
|
||||
return {
|
||||
...view,
|
||||
createdTime: new Date().toISOString(),
|
||||
createdBy: userId,
|
||||
version: 1,
|
||||
tableId: targetTableId,
|
||||
id: newViewId,
|
||||
shareId: generateShareId(),
|
||||
...updatedFields,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
// duplicate plugin view
|
||||
await this.duplicatePluginViews(
|
||||
targetTableId,
|
||||
pluginViews,
|
||||
sourceToTargetViewMap,
|
||||
sourceToTargetFieldMap
|
||||
);
|
||||
|
||||
return sourceToTargetViewMap;
|
||||
}
|
||||
|
||||
private async duplicatePluginViews(
|
||||
targetTableId: string,
|
||||
pluginViews: View[],
|
||||
sourceToTargetViewMap: Record<string, string>,
|
||||
sourceToTargetFieldMap: Record<string, string>
|
||||
) {
|
||||
const prisma = this.prismaService.txClient();
|
||||
|
||||
if (!pluginViews.length) return;
|
||||
|
||||
const pluginData = await prisma.pluginInstall.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: pluginViews.map((v) => (v.options ? JSON.parse(v.options).pluginInstallId : null)),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const view of pluginViews) {
|
||||
const plugin = view.options ? JSON.parse(view.options) : null;
|
||||
if (!plugin) {
|
||||
throw new BadGatewayException('Duplicate plugin view error: pluginId not found');
|
||||
}
|
||||
const { pluginInstallId, pluginId } = plugin;
|
||||
|
||||
const newPluginInsId = generatePluginInstallId();
|
||||
const newViewId = generateViewId();
|
||||
|
||||
sourceToTargetViewMap[view.id] = newViewId;
|
||||
|
||||
const pluginInfo = pluginData.find((p) => p.id === pluginInstallId);
|
||||
|
||||
if (!pluginInfo) continue;
|
||||
|
||||
let curPluginStorage = pluginInfo?.storage;
|
||||
let pluginOptions = plugin.options;
|
||||
|
||||
if (curPluginStorage) {
|
||||
Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {
|
||||
curPluginStorage = curPluginStorage?.replaceAll(key, value) || null;
|
||||
});
|
||||
}
|
||||
|
||||
if (pluginOptions) {
|
||||
Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {
|
||||
pluginOptions = pluginOptions.replaceAll(key, value);
|
||||
});
|
||||
pluginOptions = pluginOptions.replaceAll(pluginId, newPluginInsId);
|
||||
}
|
||||
|
||||
const fieldsToReplace = ['columnMeta', 'options', 'sort', 'group', 'filter'] as const;
|
||||
|
||||
const updatedFields = fieldsToReplace.reduce(
|
||||
(acc, field) => {
|
||||
if (view[field]) {
|
||||
acc[field] = Object.entries(sourceToTargetFieldMap).reduce(
|
||||
(result, [key, value]) => result.replaceAll(key, value),
|
||||
view[field]!
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Partial<typeof view>
|
||||
);
|
||||
|
||||
await prisma.pluginInstall.create({
|
||||
data: {
|
||||
...pluginInfo,
|
||||
createdBy: this.cls.get('user.id'),
|
||||
id: newPluginInsId,
|
||||
createdTime: new Date().toISOString(),
|
||||
lastModifiedBy: null,
|
||||
lastModifiedTime: null,
|
||||
storage: curPluginStorage,
|
||||
positionId: newViewId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.view.create({
|
||||
data: {
|
||||
...view,
|
||||
createdTime: new Date().toISOString(),
|
||||
createdBy: this.cls.get('user.id'),
|
||||
version: 1,
|
||||
tableId: targetTableId,
|
||||
id: newViewId,
|
||||
shareId: generateShareId(),
|
||||
options: pluginOptions,
|
||||
...updatedFields,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return sourceToTargetViewMap;
|
||||
}
|
||||
|
||||
private async repairDuplicateOmit(
|
||||
sourceToTargetFieldMap: Record<string, string>,
|
||||
sourceToTargetViewMap: Record<string, string>,
|
||||
targetTableId: string
|
||||
) {
|
||||
const fieldRaw = await this.prismaService.txClient().field.findMany({
|
||||
where: {
|
||||
tableId: targetTableId,
|
||||
deletedTime: null,
|
||||
},
|
||||
});
|
||||
|
||||
const selfLinkFields = fieldRaw.filter(
|
||||
({ type, options }) =>
|
||||
type === FieldType.Link &&
|
||||
options &&
|
||||
(JSON.parse(options) as ILinkFieldOptions)?.foreignTableId === targetTableId
|
||||
);
|
||||
|
||||
for (const field of selfLinkFields) {
|
||||
const { id: fieldId, options } = field;
|
||||
if (!options) continue;
|
||||
|
||||
let newOptions = options;
|
||||
|
||||
Object.entries(sourceToTargetFieldMap).forEach(([key, value]) => {
|
||||
newOptions = newOptions.replaceAll(key, value);
|
||||
});
|
||||
|
||||
Object.entries(sourceToTargetViewMap).forEach(([key, value]) => {
|
||||
newOptions = newOptions.replaceAll(key, value);
|
||||
});
|
||||
|
||||
await this.prismaService.txClient().field.update({
|
||||
where: {
|
||||
id: fieldId,
|
||||
},
|
||||
data: {
|
||||
options: newOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private extractFieldIds(expression: string): string[] {
|
||||
const matches = expression.match(/\{fld[a-zA-Z0-9]+\}/g);
|
||||
|
||||
if (!matches) {
|
||||
return [];
|
||||
}
|
||||
return matches.map((match) => match.slice(1, -1));
|
||||
}
|
||||
}
|
||||
@ -31,7 +31,7 @@ export class TableIndexService {
|
||||
tableId: string,
|
||||
type: TableIndex = TableIndex.search
|
||||
): Promise<TableIndex[]> {
|
||||
const { dbTableName } = await this.prismaService.tableMeta.findUniqueOrThrow({
|
||||
const { dbTableName } = await this.prismaService.txClient().tableMeta.findUniqueOrThrow({
|
||||
where: {
|
||||
id: tableId,
|
||||
},
|
||||
@ -142,7 +142,7 @@ export class TableIndexService {
|
||||
const index = await this.getActivatedTableIndexes(tableId);
|
||||
const sql = this.dbProvider.searchIndex().createSingleIndexSql(dbTableName, fieldInstance);
|
||||
if (index.includes(TableIndex.search) && sql) {
|
||||
await this.prismaService.$executeRawUnsafe(sql);
|
||||
await this.prismaService.txClient().$executeRawUnsafe(sql);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ export class TableService implements IReadonlyAdapterService {
|
||||
return convertNameToValidCharacter(name, 40);
|
||||
}
|
||||
|
||||
private async createDBTable(baseId: string, tableRo: ICreateTableRo) {
|
||||
private async createDBTable(baseId: string, tableRo: ICreateTableRo, createTable = true) {
|
||||
const userId = this.cls.get('user.id');
|
||||
const tableRaws = await this.prismaService.txClient().tableMeta.findMany({
|
||||
where: { baseId, deletedTime: null },
|
||||
@ -92,6 +92,10 @@ export class TableService implements IReadonlyAdapterService {
|
||||
data,
|
||||
});
|
||||
|
||||
if (!createTable) {
|
||||
return tableMeta;
|
||||
}
|
||||
|
||||
const createTableSchema = this.knex.schema.createTable(dbTableName, (table) => {
|
||||
table.string('__id').unique().notNullable();
|
||||
table.increments('__auto_number').primary();
|
||||
@ -204,8 +208,12 @@ export class TableService implements IReadonlyAdapterService {
|
||||
return viewRaw;
|
||||
}
|
||||
|
||||
async createTable(baseId: string, snapshot: ICreateTableRo): Promise<ITableVo> {
|
||||
const tableVo = await this.createDBTable(baseId, snapshot);
|
||||
async createTable(
|
||||
baseId: string,
|
||||
snapshot: ICreateTableRo,
|
||||
createTable: boolean = true
|
||||
): Promise<ITableVo> {
|
||||
const tableVo = await this.createDBTable(baseId, snapshot, createTable);
|
||||
await this.batchService.saveRawOps(baseId, RawOpType.Create, IdPrefix.Table, [
|
||||
{
|
||||
docId: tableVo.id,
|
||||
|
||||
@ -785,7 +785,7 @@ export class ViewOpenApiService {
|
||||
async pluginInstall(tableId: string, ro: IViewInstallPluginRo) {
|
||||
const userId = this.cls.get('user.id');
|
||||
const { name, pluginId } = ro;
|
||||
const plugin = await this.prismaService.plugin.findUnique({
|
||||
const plugin = await this.prismaService.txClient().plugin.findUnique({
|
||||
where: { id: pluginId, status: PluginStatus.Published },
|
||||
select: { id: true, name: true, logo: true, positions: true },
|
||||
});
|
||||
|
||||
562
apps/nestjs-backend/test/table-duplicate.e2e-spec.ts
Normal file
562
apps/nestjs-backend/test/table-duplicate.e2e-spec.ts
Normal file
@ -0,0 +1,562 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { IFieldVo, IFilterRo, ILinkFieldOptions, IViewGroupRo, IViewVo } from '@teable/core';
|
||||
import { FieldType, ViewType, RowHeightLevel, SortFunc } from '@teable/core';
|
||||
import type { IDuplicateTableVo, ITableFullVo } from '@teable/openapi';
|
||||
import {
|
||||
createField,
|
||||
getFields,
|
||||
duplicateTable,
|
||||
installViewPlugin,
|
||||
updateViewColumnMeta,
|
||||
updateViewSort,
|
||||
updateViewGroup,
|
||||
updateViewOptions,
|
||||
getField,
|
||||
} from '@teable/openapi';
|
||||
import { omit } from 'lodash';
|
||||
import { x_20 } from './data-helpers/20x';
|
||||
import { x_20_link, x_20_link_from_lookups } from './data-helpers/20x-link';
|
||||
|
||||
import {
|
||||
createTable,
|
||||
permanentDeleteTable,
|
||||
initApp,
|
||||
getViews,
|
||||
deleteField,
|
||||
createView,
|
||||
updateViewFilter,
|
||||
} from './utils/init-app';
|
||||
|
||||
describe('OpenAPI TableController for duplicate (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
const baseId = globalThis.testConfig.baseId;
|
||||
|
||||
beforeAll(async () => {
|
||||
const appCtx = await initApp();
|
||||
app = appCtx.app;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('duplicate table with all kind field', () => {
|
||||
let table: ITableFullVo;
|
||||
let subTable: ITableFullVo;
|
||||
let duplicateTableData: IDuplicateTableVo;
|
||||
beforeAll(async () => {
|
||||
table = await createTable(baseId, {
|
||||
name: 'record_query_x_20',
|
||||
fields: x_20.fields,
|
||||
records: x_20.records,
|
||||
});
|
||||
|
||||
const x20Link = x_20_link(table);
|
||||
subTable = await createTable(baseId, {
|
||||
name: 'lookup_filter_x_20',
|
||||
fields: x20Link.fields,
|
||||
records: x20Link.records,
|
||||
});
|
||||
|
||||
const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);
|
||||
for (const field of x20LinkFromLookups.fields) {
|
||||
await createField(subTable.id, field);
|
||||
}
|
||||
|
||||
table.fields = (await getFields(table.id)).data;
|
||||
table.views = await getViews(table.id);
|
||||
subTable.fields = (await getFields(subTable.id)).data;
|
||||
duplicateTableData = (
|
||||
await duplicateTable(baseId, table.id, {
|
||||
name: 'duplicated_table',
|
||||
includeRecords: false,
|
||||
})
|
||||
).data;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await permanentDeleteTable(baseId, table.id);
|
||||
await permanentDeleteTable(baseId, subTable.id);
|
||||
await permanentDeleteTable(baseId, duplicateTableData.id);
|
||||
});
|
||||
|
||||
it('should duplicate all fields and views', () => {
|
||||
const { fields: sourceFields, views: sourceViews } = table;
|
||||
const { fields: targetFields, views: targetViews, viewMap, fieldMap } = duplicateTableData;
|
||||
|
||||
expect(targetFields.length).toBe(sourceFields.length);
|
||||
expect(sourceViews.length).toBe(targetViews.length);
|
||||
|
||||
let sourceViewsString = JSON.stringify(sourceViews);
|
||||
let sourceFieldsString = JSON.stringify(sourceFields);
|
||||
for (const [key, value] of Object.entries(viewMap)) {
|
||||
sourceViewsString = sourceViewsString.replaceAll(key, value);
|
||||
sourceFieldsString = sourceFieldsString.replaceAll(key, value);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(fieldMap)) {
|
||||
sourceViewsString = sourceViewsString.replaceAll(key, value);
|
||||
sourceFieldsString = sourceFieldsString.replaceAll(key, value);
|
||||
}
|
||||
|
||||
const assertField = JSON.parse(sourceFieldsString) as IFieldVo[];
|
||||
const assertViews = JSON.parse(sourceViewsString) as IViewVo[];
|
||||
|
||||
const assertLinkField = assertField
|
||||
.filter(({ type }) => type === FieldType.Link)
|
||||
.map((f) => ({
|
||||
...f,
|
||||
options: omit(
|
||||
{
|
||||
...f.options,
|
||||
isOneWay: !!(f?.options as ILinkFieldOptions)?.isOneWay,
|
||||
},
|
||||
['fkHostTableName', 'selfKeyName', 'symmetricFieldId']
|
||||
),
|
||||
}));
|
||||
const duplicatedLinkField = targetFields
|
||||
.filter(({ type }) => type === FieldType.Link)
|
||||
.map((f) => ({
|
||||
...f,
|
||||
options: omit(
|
||||
{
|
||||
...f.options,
|
||||
isOneWay: !!(f?.options as ILinkFieldOptions)?.isOneWay,
|
||||
},
|
||||
['fkHostTableName', 'selfKeyName', 'symmetricFieldId']
|
||||
),
|
||||
}));
|
||||
|
||||
const otherFieldsWithOutLink = assertField
|
||||
.filter(({ type, isLookup }) => type !== FieldType.Link && !isLookup)
|
||||
.map((f) => omit(f, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy']));
|
||||
const otherAssertFieldsWithOutLink = targetFields
|
||||
.filter(({ type, isLookup }) => type !== FieldType.Link && !isLookup)
|
||||
.map((f) => omit(f, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy']));
|
||||
|
||||
const duplicatedViews = targetViews.map((v) =>
|
||||
omit(v, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy', 'shareId'])
|
||||
);
|
||||
|
||||
const assertPureViews = assertViews.map((v) =>
|
||||
omit(v, ['createdBy', 'createdTime', 'lastModifiedTime', 'lastModifiedBy', 'shareId'])
|
||||
);
|
||||
|
||||
const sortById = (a: any, b: any) => a.id.localeCompare(b.id);
|
||||
|
||||
expect(assertPureViews).toEqual(duplicatedViews);
|
||||
expect(assertLinkField).toEqual(duplicatedLinkField);
|
||||
expect(otherFieldsWithOutLink.sort(sortById)).toEqual(
|
||||
otherAssertFieldsWithOutLink.sort(sortById)
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a link field in linked table when link field is two-way-link', async () => {
|
||||
const fields = (await getFields(subTable.id)).data;
|
||||
const { fields: targetFields } = duplicateTableData;
|
||||
const assertField = targetFields.find(({ type }) => type === FieldType.Link)!;
|
||||
const duplicatedLinkField = fields.find(
|
||||
(f) =>
|
||||
f.type === FieldType.Link &&
|
||||
(f.options as ILinkFieldOptions).symmetricFieldId === assertField.id!
|
||||
);
|
||||
expect(duplicatedLinkField).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate table with error field(formula or lookup field)', () => {
|
||||
let table: ITableFullVo;
|
||||
let subTable: ITableFullVo;
|
||||
let duplicateTableData: IDuplicateTableVo;
|
||||
let lookupField: IFieldVo;
|
||||
let formulaField: IFieldVo;
|
||||
beforeAll(async () => {
|
||||
table = await createTable(baseId, {
|
||||
name: 'record_query_x_20',
|
||||
fields: x_20.fields,
|
||||
records: x_20.records,
|
||||
});
|
||||
|
||||
const x20Link = x_20_link(table);
|
||||
subTable = await createTable(baseId, {
|
||||
name: 'lookup_filter_x_20',
|
||||
fields: x20Link.fields,
|
||||
records: x20Link.records,
|
||||
});
|
||||
|
||||
const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);
|
||||
for (const field of x20LinkFromLookups.fields) {
|
||||
await createField(subTable.id, field);
|
||||
}
|
||||
|
||||
table.fields = (await getFields(table.id)).data;
|
||||
table.views = await getViews(table.id);
|
||||
subTable.fields = (await getFields(subTable.id)).data;
|
||||
|
||||
const primaryField = table.fields.find((f) => f.isPrimary)!;
|
||||
const numberField = table.fields.find((f) => f.type === FieldType.Number)!;
|
||||
const linkField = table.fields.find((f) => f.type === FieldType.Link)!;
|
||||
const lookupedField = subTable.fields.find((f) => f.type === FieldType.Number)!;
|
||||
|
||||
// create a formula field and a lookup field both in degree same field, then delete the field, causing field hasError
|
||||
formulaField = (
|
||||
await createField(table.id, {
|
||||
name: 'error_formulaField',
|
||||
type: FieldType.Formula,
|
||||
options: {
|
||||
expression: `{${primaryField.id}}+{${numberField.id}}`,
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
lookupField = (
|
||||
await createField(table.id, {
|
||||
name: 'error_lookupField',
|
||||
type: lookupedField.type,
|
||||
isLookup: true,
|
||||
lookupOptions: {
|
||||
foreignTableId: subTable.id,
|
||||
linkFieldId: linkField.id,
|
||||
lookupFieldId: lookupedField.id,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
|
||||
await deleteField(table.id, numberField.id);
|
||||
await deleteField(subTable.id, lookupedField.id);
|
||||
|
||||
duplicateTableData = (
|
||||
await duplicateTable(baseId, table.id, {
|
||||
name: 'duplicated_table',
|
||||
includeRecords: false,
|
||||
})
|
||||
).data;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await permanentDeleteTable(baseId, table.id);
|
||||
await permanentDeleteTable(baseId, subTable.id);
|
||||
await permanentDeleteTable(baseId, duplicateTableData.id);
|
||||
});
|
||||
|
||||
it('duplicated formula and lookup field should has error', async () => {
|
||||
const sourceFields = (await getFields(table.id)).data;
|
||||
|
||||
const { fields: targetFields, fieldMap } = duplicateTableData;
|
||||
const sourceErrorFormulaField = sourceFields.find((f) => f.id === formulaField.id);
|
||||
const sourceErrorLookupField = sourceFields.find((f) => f.id === lookupField.id);
|
||||
expect(sourceErrorFormulaField?.hasError).toBe(true);
|
||||
expect(sourceErrorLookupField?.hasError).toBe(true);
|
||||
|
||||
const targetErrorFormulaField = targetFields.find((f) => f.id === fieldMap[formulaField.id]);
|
||||
const targetErrorLookupField = targetFields.find((f) => f.id === fieldMap[lookupField.id]);
|
||||
expect(targetErrorFormulaField?.hasError).toBe(true);
|
||||
expect(targetErrorLookupField?.hasError).toBe(true);
|
||||
|
||||
let assertErrorFormulaFieldString = JSON.stringify(sourceErrorFormulaField);
|
||||
// let assertErrorLookupFieldString = JSON.stringify(sourceErrorLookupField);
|
||||
for (const [key, value] of Object.entries(fieldMap)) {
|
||||
assertErrorFormulaFieldString = assertErrorFormulaFieldString.replaceAll(key, value);
|
||||
// assertErrorLookupFieldString = assertErrorLookupFieldString.replaceAll(key, value);
|
||||
}
|
||||
|
||||
const assertErrorFormulaField = JSON.parse(assertErrorFormulaFieldString);
|
||||
// const assertErrorLookupField = JSON.parse(assertErrorLookupFieldString);
|
||||
expect(assertErrorFormulaField).toEqual(targetErrorFormulaField);
|
||||
expect(targetErrorLookupField?.hasError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate table with self link', () => {
|
||||
let table: ITableFullVo;
|
||||
let subTable: ITableFullVo;
|
||||
let duplicateTableData: IDuplicateTableVo;
|
||||
beforeAll(async () => {
|
||||
table = await createTable(baseId, {
|
||||
name: 'record_query_x_20',
|
||||
fields: x_20.fields,
|
||||
records: x_20.records,
|
||||
});
|
||||
|
||||
const x20Link = x_20_link(table);
|
||||
subTable = await createTable(baseId, {
|
||||
name: 'lookup_filter_x_20',
|
||||
fields: x20Link.fields,
|
||||
records: x20Link.records,
|
||||
});
|
||||
|
||||
const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);
|
||||
for (const field of x20LinkFromLookups.fields) {
|
||||
await createField(subTable.id, field);
|
||||
}
|
||||
|
||||
table.fields = (await getFields(table.id)).data;
|
||||
table.views = await getViews(table.id);
|
||||
subTable.fields = (await getFields(subTable.id)).data;
|
||||
|
||||
await createField(table.id, {
|
||||
name: 'self_link',
|
||||
type: FieldType.Link,
|
||||
options: {
|
||||
visibleFieldIds: null,
|
||||
foreignTableId: table.id,
|
||||
relationship: 'manyMany',
|
||||
filter: null,
|
||||
filterByViewId: null,
|
||||
},
|
||||
});
|
||||
|
||||
duplicateTableData = (
|
||||
await duplicateTable(baseId, table.id, {
|
||||
name: 'duplicated_table',
|
||||
includeRecords: false,
|
||||
})
|
||||
).data;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await permanentDeleteTable(baseId, table.id);
|
||||
await permanentDeleteTable(baseId, subTable.id);
|
||||
await permanentDeleteTable(baseId, duplicateTableData.id);
|
||||
});
|
||||
|
||||
it('should duplicate self link fields', async () => {
|
||||
const { fields, id } = duplicateTableData;
|
||||
|
||||
const selfLinkFields = fields.filter(
|
||||
(f) => f.type === FieldType.Link && (f.options as ILinkFieldOptions)?.foreignTableId === id
|
||||
);
|
||||
|
||||
expect(selfLinkFields.length).toBe(2);
|
||||
expect((selfLinkFields[0].options as ILinkFieldOptions).fkHostTableName).toBe(
|
||||
(selfLinkFields[1].options as ILinkFieldOptions).fkHostTableName
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate table with all type view', () => {
|
||||
let table: ITableFullVo;
|
||||
let subTable: ITableFullVo;
|
||||
let duplicateTableData: IDuplicateTableVo;
|
||||
beforeAll(async () => {
|
||||
table = await createTable(baseId, {
|
||||
name: 'record_query_x_20',
|
||||
fields: x_20.fields,
|
||||
records: x_20.records,
|
||||
});
|
||||
|
||||
const x20Link = x_20_link(table);
|
||||
subTable = await createTable(baseId, {
|
||||
name: 'lookup_filter_x_20',
|
||||
fields: x20Link.fields,
|
||||
records: x20Link.records,
|
||||
});
|
||||
|
||||
const x20LinkFromLookups = x_20_link_from_lookups(table, subTable.fields[2].id);
|
||||
for (const field of x20LinkFromLookups.fields) {
|
||||
await createField(subTable.id, field);
|
||||
}
|
||||
|
||||
table.fields = (await getFields(table.id)).data;
|
||||
table.views = await getViews(table.id);
|
||||
subTable.fields = (await getFields(subTable.id)).data;
|
||||
|
||||
await createField(table.id, {
|
||||
name: 'self_link',
|
||||
type: FieldType.Link,
|
||||
options: {
|
||||
visibleFieldIds: null,
|
||||
foreignTableId: table.id,
|
||||
relationship: 'manyMany',
|
||||
filter: null,
|
||||
filterByViewId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await permanentDeleteTable(baseId, table.id);
|
||||
await permanentDeleteTable(baseId, subTable.id);
|
||||
await permanentDeleteTable(baseId, duplicateTableData.id);
|
||||
});
|
||||
|
||||
it('should duplicate all kind of views', async () => {
|
||||
const gridView = (await getViews(table.id))[0];
|
||||
|
||||
const filterRo: IFilterRo = {
|
||||
filter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [
|
||||
{
|
||||
fieldId: table.fields.find((f) => f.isPrimary)!.id,
|
||||
operator: 'contains',
|
||||
value: 'text field',
|
||||
},
|
||||
{
|
||||
conjunction: 'and',
|
||||
filterSet: [
|
||||
{
|
||||
fieldId: table.fields.find((f) => f.type === FieldType.Number)!.id,
|
||||
operator: 'isGreater',
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldId: table.fields.find((f) => f.type === FieldType.SingleSelect)!.id,
|
||||
operator: 'is',
|
||||
value: 'x',
|
||||
},
|
||||
{
|
||||
fieldId: table.fields.find((f) => f.type === FieldType.Checkbox)!.id,
|
||||
operator: 'is',
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const groupRo: IViewGroupRo = {
|
||||
group: [
|
||||
{
|
||||
fieldId: table.fields.find((f) => f.isPrimary)!.id,
|
||||
order: SortFunc.Asc,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const sortRo = {
|
||||
sort: {
|
||||
sortObjs: [
|
||||
{
|
||||
fieldId: table.fields.find((f) => f.type === FieldType.MultipleSelect)!.id,
|
||||
order: SortFunc.Asc,
|
||||
},
|
||||
{
|
||||
fieldId: table.fields.find((f) => f.type === FieldType.Formula)!.id,
|
||||
order: SortFunc.Desc,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await createView(table.id, {
|
||||
name: 'gallery',
|
||||
type: ViewType.Gallery,
|
||||
filter: filterRo.filter,
|
||||
group: groupRo.group,
|
||||
sort: sortRo.sort,
|
||||
enableShare: true,
|
||||
});
|
||||
|
||||
await createView(table.id, {
|
||||
name: 'kanban',
|
||||
type: ViewType.Kanban,
|
||||
group: groupRo.group,
|
||||
sort: sortRo.sort,
|
||||
options: {
|
||||
stackFieldId: table.fields.find((f) => f.isPrimary)!.id,
|
||||
},
|
||||
});
|
||||
|
||||
await createView(table.id, {
|
||||
name: 'calendar',
|
||||
type: ViewType.Calendar,
|
||||
filter: filterRo.filter,
|
||||
});
|
||||
|
||||
await createView(table.id, {
|
||||
name: 'table',
|
||||
type: ViewType.Form,
|
||||
columnMeta: {
|
||||
[table.fields.find((f) => f.isPrimary)!.id]: {
|
||||
visible: true,
|
||||
order: 1,
|
||||
},
|
||||
[table.fields.find((f) => f.type === FieldType.Number)!.id]: {
|
||||
visible: true,
|
||||
order: 2,
|
||||
},
|
||||
[table.fields.find((f) => f.type === FieldType.SingleSelect)!.id]: {
|
||||
visible: true,
|
||||
order: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await installViewPlugin(table.id, {
|
||||
name: 'sheet',
|
||||
pluginId: 'plgsheetform',
|
||||
});
|
||||
|
||||
await updateViewFilter(table.id, gridView.id, filterRo);
|
||||
|
||||
await updateViewColumnMeta(table.id, gridView.id, [
|
||||
{
|
||||
fieldId: table.fields.find((f) => f.type === FieldType.User)!.id,
|
||||
columnMeta: { hidden: true },
|
||||
},
|
||||
]);
|
||||
|
||||
await updateViewSort(table.id, gridView.id, sortRo);
|
||||
|
||||
await updateViewGroup(table.id, gridView.id, groupRo);
|
||||
|
||||
await updateViewOptions(table.id, gridView.id, {
|
||||
options: {
|
||||
rowHeight: RowHeightLevel.Tall,
|
||||
},
|
||||
});
|
||||
|
||||
const sourceViews = await getViews(table.id);
|
||||
|
||||
duplicateTableData = (
|
||||
await duplicateTable(baseId, table.id, {
|
||||
name: 'duplicated_table',
|
||||
includeRecords: false,
|
||||
})
|
||||
).data;
|
||||
|
||||
const targetViews = await getViews(duplicateTableData.id);
|
||||
|
||||
const { fieldMap } = duplicateTableData;
|
||||
expect(sourceViews.length).toBe(targetViews.length);
|
||||
let assertViewsString = JSON.stringify(
|
||||
sourceViews
|
||||
.filter((f) => f.type !== ViewType.Plugin)
|
||||
.map((v) => ({
|
||||
...omit(v, [
|
||||
'createdBy',
|
||||
'createdTime',
|
||||
'lastModifiedBy',
|
||||
'lastModifiedTime',
|
||||
'shareId',
|
||||
'id',
|
||||
]),
|
||||
options: omit(v.options, ['pluginId', 'pluginInstallId']),
|
||||
}))
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(fieldMap)) {
|
||||
assertViewsString = assertViewsString.replaceAll(key, value);
|
||||
}
|
||||
|
||||
const assertViews = JSON.parse(assertViewsString);
|
||||
|
||||
expect(assertViews).toEqual(
|
||||
targetViews
|
||||
.filter((f) => f.type !== ViewType.Plugin)
|
||||
.map((v) => ({
|
||||
...omit(v, [
|
||||
'createdBy',
|
||||
'createdTime',
|
||||
'lastModifiedBy',
|
||||
'lastModifiedTime',
|
||||
'shareId',
|
||||
'id',
|
||||
]),
|
||||
options: omit(v.options, ['pluginId', 'pluginInstallId']),
|
||||
}))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -80,7 +80,7 @@ export const TableList: React.FC = () => {
|
||||
fileType={fileType}
|
||||
open={dialogVisible}
|
||||
onOpenChange={(open) => setDialogVisible(open)}
|
||||
></TableImport>
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto px-3">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getUniqName } from '@teable/core';
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@ -8,10 +9,11 @@ import {
|
||||
Import,
|
||||
FileCsv,
|
||||
FileExcel,
|
||||
Copy,
|
||||
} from '@teable/icons';
|
||||
import { SUPPORTEDTYPE } from '@teable/openapi';
|
||||
import { duplicateTable, SUPPORTEDTYPE } from '@teable/openapi';
|
||||
import { ReactQueryKeys } from '@teable/sdk/config';
|
||||
import { useBase, useTables } from '@teable/sdk/hooks';
|
||||
import { useBase, useBasePermission, useTables } from '@teable/sdk/hooks';
|
||||
import type { Table } from '@teable/sdk/model';
|
||||
import { ConfirmDialog } from '@teable/ui-lib/base';
|
||||
import {
|
||||
@ -23,6 +25,9 @@ import {
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
Switch,
|
||||
Label,
|
||||
Input,
|
||||
} from '@teable/ui-lib/shadcn';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
@ -44,8 +49,10 @@ export const TableOperation = (props: ITableOperationProps) => {
|
||||
const { table, className, onRename, open, setOpen } = props;
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [importVisible, setImportVisible] = useState(false);
|
||||
const [duplicateSetting, setDuplicateSetting] = useState(false);
|
||||
const [importType, setImportType] = useState(SUPPORTEDTYPE.CSV);
|
||||
const base = useBase();
|
||||
const permission = useBasePermission();
|
||||
const tables = useTables();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@ -53,14 +60,29 @@ export const TableOperation = (props: ITableOperationProps) => {
|
||||
const { t } = useTranslation(tableConfig.i18nNamespaces);
|
||||
const { trigger } = useDownload({ downloadUrl: `/api/export/${table.id}`, 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'],
|
||||
};
|
||||
}, [table.permission]);
|
||||
}, [permission, table.permission]);
|
||||
|
||||
const deleteTable = async () => {
|
||||
const tableId = table?.id;
|
||||
@ -86,6 +108,23 @@ export const TableOperation = (props: ITableOperationProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const { mutateAsync: duplicateTableFn, isLoading } = useMutation({
|
||||
mutationFn: () => duplicateTable(baseId as string, table.id, duplicateOption),
|
||||
onSuccess: (data) => {
|
||||
const {
|
||||
data: { id },
|
||||
} = data;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ReactQueryKeys.tableList(baseId as string),
|
||||
});
|
||||
setDuplicateSetting(false);
|
||||
router.push({
|
||||
pathname: '/base/[baseId]/[tableId]',
|
||||
query: { baseId, tableId: id },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!Object.values(menuPermission).some(Boolean)) {
|
||||
return null;
|
||||
}
|
||||
@ -122,6 +161,12 @@ export const TableOperation = (props: ITableOperationProps) => {
|
||||
{t('table:table.design')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{menuPermission.duplicateTable && (
|
||||
<DropdownMenuItem onClick={() => setDuplicateSetting(true)}>
|
||||
<Copy className="mr-2" />
|
||||
{t('table:import.menu.duplicate')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{menuPermission.exportTable && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@ -195,6 +240,46 @@ export const TableOperation = (props: ITableOperationProps) => {
|
||||
onCancel={() => setDeleteConfirm(false)}
|
||||
onConfirm={deleteTable}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={duplicateSetting}
|
||||
onOpenChange={setDuplicateSetting}
|
||||
title={`${t('common:actions.duplicate')} ${table?.name}`}
|
||||
cancelText={t('common:actions.cancel')}
|
||||
confirmText={t('common:actions.duplicate')}
|
||||
confirmLoading={isLoading}
|
||||
content={
|
||||
<div className="flex flex-col space-y-2 text-sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>
|
||||
{t('common:noun.table')} {t('common:name')}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultTableName}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setDuplicateOption((prev) => ({ ...prev, name: value }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Switch
|
||||
id="include-record"
|
||||
checked={duplicateOption.includeRecords}
|
||||
onCheckedChange={(val) => {
|
||||
setDuplicateOption((prev) => ({ ...prev, includeRecords: val }));
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="include-record">{t('table:import.menu.includeRecords')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onCancel={() => setDuplicateSetting(false)}
|
||||
onConfirm={async () => {
|
||||
duplicateTableFn();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -359,7 +359,9 @@
|
||||
"cancel": "Cancel",
|
||||
"leave": "Leave",
|
||||
"downAsCsv": "Download CSV",
|
||||
"importData": "Import Data"
|
||||
"importData": "Import Data",
|
||||
"duplicate": "Duplicate table",
|
||||
"includeRecords": "Include records"
|
||||
},
|
||||
"tips": {
|
||||
"importWayTip": "Click or drag file to this area to upload",
|
||||
|
||||
@ -358,7 +358,10 @@
|
||||
"cancel": "取消",
|
||||
"leave": "取消",
|
||||
"downAsCsv": "下载 CSV",
|
||||
"importData": "导入数据"
|
||||
"importData": "导入数据",
|
||||
"duplicate": "复制数据表",
|
||||
"importing": "导入中",
|
||||
"includeRecords": "包含记录"
|
||||
},
|
||||
"tips": {
|
||||
"importWayTip": "点击或者拖拽到此区域上传",
|
||||
|
||||
65
packages/openapi/src/table/duplicate.ts
Normal file
65
packages/openapi/src/table/duplicate.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { RouteConfig } from '@asteasolutions/zod-to-openapi';
|
||||
import { axios } from '../axios';
|
||||
import { registerRoute, urlBuilder } from '../utils';
|
||||
import { z } from '../zod';
|
||||
import { tableFullVoSchema } from './create';
|
||||
|
||||
export const DUPLICATE_TABLE = '/base/{baseId}/table/{tableId}/duplicate';
|
||||
|
||||
export const duplicateTableRoSchema = z.object({
|
||||
name: z.string(),
|
||||
includeRecords: z.boolean(),
|
||||
});
|
||||
|
||||
export const duplicateTableVoSchema = tableFullVoSchema
|
||||
.omit({
|
||||
records: true,
|
||||
})
|
||||
.extend({
|
||||
viewMap: z.record(z.string()),
|
||||
fieldMap: z.record(z.string()),
|
||||
});
|
||||
|
||||
export type IDuplicateTableVo = z.infer<typeof duplicateTableVoSchema>;
|
||||
|
||||
export type IDuplicateTableRo = z.infer<typeof duplicateTableRoSchema>;
|
||||
|
||||
export const DuplicateTableRoute: RouteConfig = registerRoute({
|
||||
method: 'post',
|
||||
path: DUPLICATE_TABLE,
|
||||
description: 'Duplicate a table',
|
||||
summary: 'Duplicate a table',
|
||||
request: {
|
||||
params: z.object({
|
||||
baseId: z.string(),
|
||||
tableId: z.string(),
|
||||
}),
|
||||
body: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: duplicateTableRoSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Duplicate successfully',
|
||||
},
|
||||
},
|
||||
tags: ['table'],
|
||||
});
|
||||
|
||||
export const duplicateTable = async (
|
||||
baseId: string,
|
||||
tableId: string,
|
||||
duplicateRo: IDuplicateTableRo
|
||||
) => {
|
||||
return axios.post<IDuplicateTableVo>(
|
||||
urlBuilder(DUPLICATE_TABLE, {
|
||||
baseId,
|
||||
tableId,
|
||||
}),
|
||||
duplicateRo
|
||||
);
|
||||
};
|
||||
@ -14,3 +14,4 @@ export * from './toggle-table-index';
|
||||
export * from './get-activated-index';
|
||||
export * from './get-abnormal-index';
|
||||
export * from './repair-table-index';
|
||||
export * from './duplicate';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user