diff --git a/apps/nestjs-backend/src/features/base/base-duplicate.service.ts b/apps/nestjs-backend/src/features/base/base-duplicate.service.ts index 2c892e9bf4..103ad6ef89 100644 --- a/apps/nestjs-backend/src/features/base/base-duplicate.service.ts +++ b/apps/nestjs-backend/src/features/base/base-duplicate.service.ts @@ -28,13 +28,14 @@ export class BaseDuplicateService { ) {} async duplicateBase(duplicateBaseRo: IDuplicateBaseRo, allowCrossBase: boolean = true) { - const { fromBaseId, spaceId, withRecords, name } = duplicateBaseRo; + const { fromBaseId, spaceId, withRecords, name, baseId } = duplicateBaseRo; const { base, tableIdMap, fieldIdMap, viewIdMap } = await this.duplicateStructure( fromBaseId, spaceId, name, - allowCrossBase + allowCrossBase, + baseId ); const crossBaseLinkFieldTableMap = allowCrossBase @@ -105,7 +106,8 @@ export class BaseDuplicateService { fromBaseId: string, spaceId: string, baseName?: string, - allowCrossBase?: boolean + allowCrossBase?: boolean, + baseId?: string ) { const prisma = this.prismaService.txClient(); const baseRaw = await prisma.base.findUniqueOrThrow({ @@ -160,7 +162,7 @@ export class BaseDuplicateService { tableIdMap, fieldIdMap, viewIdMap, - } = await this.baseImportService.createBaseStructure(spaceId, structure); + } = await this.baseImportService.createBaseStructure(spaceId, structure, baseId); return { base: newBase, tableIdMap, fieldIdMap, viewIdMap }; } diff --git a/apps/nestjs-backend/src/features/base/base-import.service.ts b/apps/nestjs-backend/src/features/base/base-import.service.ts index 3895944c10..596867f281 100644 --- a/apps/nestjs-backend/src/features/base/base-import.service.ts +++ b/apps/nestjs-backend/src/features/base/base-import.service.ts @@ -225,11 +225,21 @@ export class BaseImportService { ); } - async createBaseStructure(spaceId: string, structure: IBaseJson) { + async createBaseStructure(spaceId: string, structure: IBaseJson, baseId?: string) { const { name, icon, tables, plugins } = structure; // create base - const newBase = await this.createBase(spaceId, name, icon || undefined); + const newBase = baseId + ? await this.prismaService.base.findUniqueOrThrow({ + where: { id: baseId }, + select: { + id: true, + name: true, + icon: true, + spaceId: true, + }, + }) + : await this.createBase(spaceId, name, icon || undefined); this.logger.log(`base-duplicate-service: Duplicate base successfully`); // create table diff --git a/apps/nestjs-backend/src/features/base/base.service.ts b/apps/nestjs-backend/src/features/base/base.service.ts index 6a5dbdd45b..04ecf5d898 100644 --- a/apps/nestjs-backend/src/features/base/base.service.ts +++ b/apps/nestjs-backend/src/features/base/base.service.ts @@ -1,4 +1,10 @@ -import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { ActionPrefix, actionPrefixMap, generateBaseId } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import { CollaboratorType, ResourceType } from '@teable/openapi'; @@ -287,7 +293,7 @@ export class BaseService { } async createBaseFromTemplate(createBaseFromTemplateRo: ICreateBaseFromTemplateRo) { - const { spaceId, templateId, withRecords } = createBaseFromTemplateRo; + const { spaceId, templateId, withRecords, baseId } = createBaseFromTemplateRo; const template = await this.prismaService.template.findUniqueOrThrow({ where: { id: templateId }, select: { @@ -296,6 +302,22 @@ export class BaseService { }, }); + if (baseId) { + // check the base update permission + await this.checkBaseUpdatePermission(baseId); + + const base = await this.prismaService.base.findUniqueOrThrow({ + where: { id: baseId, deletedTime: null }, + select: { + spaceId: true, + }, + }); + + if (base.spaceId !== spaceId) { + throw new BadRequestException('baseId and spaceId mismatch'); + } + } + const { baseId: fromBaseId = '' } = template?.snapshot ? JSON.parse(template.snapshot) : {}; if (!template || !fromBaseId) { @@ -309,6 +331,7 @@ export class BaseService { fromBaseId, spaceId, withRecords, + baseId, }); await this.prismaService.template.update({ where: { id: templateId }, 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..528d757b68 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 @@ -174,6 +174,11 @@ export class TableOpenApiService { const preparedFields = await this.prepareFields(tableId, tableRo.fields); + // set the first field to be the primary field if not set + if (!preparedFields.find((field) => field.isPrimary)) { + preparedFields[0].isPrimary = true; + } + // create teable should not set computed field isPending, because noting need to calculate when create preparedFields.forEach((field) => delete field.isPending); const fieldVos = await this.createFields(tableId, preparedFields); diff --git a/apps/nestjs-backend/test/template.e2e-spec.ts b/apps/nestjs-backend/test/template.e2e-spec.ts index d2085a0350..d2e7147767 100644 --- a/apps/nestjs-backend/test/template.e2e-spec.ts +++ b/apps/nestjs-backend/test/template.e2e-spec.ts @@ -1,22 +1,28 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; import { PrismaService } from '@teable/db-main-prisma'; +import type { ITableFullVo } from '@teable/openapi'; import { createBase, + createBaseFromTemplate, createSpace, + createTable, createTemplate, createTemplateCategory, createTemplateSnapshot, deleteBase, deleteTemplate, deleteTemplateCategory, + getFields, getPublishedTemplateList, + getTableList, getTemplateCategoryList, getTemplateList, pinTopTemplate, updateTemplate, updateTemplateCategory, } from '@teable/openapi'; +import { omit } from 'lodash'; import { deleteSpace, initApp } from './utils/init-app'; describe('Template Open API Controller (e2e)', () => { @@ -216,4 +222,110 @@ describe('Template Open API Controller (e2e)', () => { expect(res2.data.length).toBe(0); }); }); + + describe('Create Base From Template', () => { + let templateId: string; + let templateBaseId: string; + let table1: ITableFullVo; + let table2: ITableFullVo; + beforeEach(async () => { + // create a template in a base + const templateBase = await createBase({ + name: 'Template Base', + spaceId, + }); + templateBaseId = templateBase.data.id; + table1 = ( + await createTable(templateBaseId, { + name: 'table1', + }) + ).data; + + table2 = ( + await createTable(templateBaseId, { + name: 'table2', + }) + ).data; + + // use this base to be a template + const template = await createTemplate({}); + templateId = template.data.id; + + await updateTemplate(template.data.id, { + name: 'test Template', + description: 'test Template description', + baseId: templateBaseId, + }); + + await createTemplateSnapshot(template.data.id); + + await updateTemplate(template.data.id, { + isPublished: true, + }); + }); + + afterEach(async () => { + await deleteBase(templateBaseId); + }); + + it('should create base from template', async () => { + const createBaseRes = ( + await createBaseFromTemplate({ + spaceId, + templateId, + withRecords: true, + }) + ).data; + const createdBaseId = createBaseRes.id; + const tables = (await getTableList(createdBaseId)).data; + // table + expect(tables.length).toBe(2); + expect(tables[0].name).toBe('table1'); + expect(tables[1].name).toBe('table2'); + const table1Fields = (await getFields(tables[0].id)).data?.map((f) => omit(f, ['id'])); + const table2Fields = (await getFields(tables[1].id)).data?.map((f) => omit(f, ['id'])); + + // fields + const originalTable1Fields = table1.fields.map((f) => omit(f, ['id'])); + const originalTable2Fields = table2.fields.map((f) => omit(f, ['id'])); + expect(table1Fields).toEqual(originalTable1Fields); + expect(table2Fields).toEqual(originalTable2Fields); + }); + + it('should apply template to a base', async () => { + const applyBase = await createBase({ + name: 'Apply Base', + spaceId, + }); + + // remain original base table + await createTable(applyBase.data.id, { + name: 'table3', + }); + + const createBaseRes = ( + await createBaseFromTemplate({ + spaceId, + templateId, + withRecords: true, + baseId: applyBase.data.id, + }) + ).data; + + const createdBaseId = createBaseRes.id; + const tables = (await getTableList(createdBaseId)).data; + // table + expect(tables.length).toBe(3); + expect(tables[1].name).toBe('table1'); + expect(tables[2].name).toBe('table2'); + const table1Fields = (await getFields(tables[1].id)).data?.map((f) => omit(f, ['id'])); + const table2Fields = (await getFields(tables[2].id)).data?.map((f) => omit(f, ['id'])); + + // fields + const originalTable1Fields = table1.fields.map((f) => omit(f, ['id'])); + const originalTable2Fields = table2.fields.map((f) => omit(f, ['id'])); + expect(table1Fields).toEqual(originalTable1Fields); + expect(table2Fields).toEqual(originalTable2Fields); + }); + }); }); diff --git a/apps/nextjs-app/src/features/app/base/ChatPage/ChatPage.tsx b/apps/nextjs-app/src/features/app/base/ChatPage/ChatPage.tsx new file mode 100644 index 0000000000..4e659b1a49 --- /dev/null +++ b/apps/nextjs-app/src/features/app/base/ChatPage/ChatPage.tsx @@ -0,0 +1,72 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPublishedTemplateCategoryList, getPublishedTemplateList } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useBaseId } from '@teable/sdk/hooks'; +import { cn } from '@teable/ui-lib/shadcn'; +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ChatContainerRef } from '../../components/ai-chat/panel/ChatContainer'; +import { PanelContainer } from '../../components/ai-chat/panel/PanelContainer'; +import { useChatPanelStore } from '../../components/ai-chat/store/useChatPanelStore'; +import { PromptBox } from './PromptBox'; +import { Template } from './Template'; + +const DEFAULT_PANEL_WIDTH = '400px'; + +export const ChatPage = () => { + const { t } = useTranslation(['common']); + const { data: TemplateCategoryList } = useQuery({ + queryKey: ReactQueryKeys.templateCategoryList(), + queryFn: () => getPublishedTemplateCategoryList().then((data) => data.data), + }); + + const baseId = useBaseId(); + const chatContainerRef = useRef(null); + + const { isVisible, close, updateWidth } = useChatPanelStore(); + + useEffect(() => { + close(); + updateWidth(DEFAULT_PANEL_WIDTH); + }, [close, updateWidth]); + + const { data: TemplateList } = useQuery({ + queryKey: ReactQueryKeys.templateList(), + queryFn: () => getPublishedTemplateList().then((data) => data.data), + }); + + return ( +
+
+

+ {t('template.aiTitle')} +

+ +

{t('template.aiSubTitle')}

+
+ +
+ { + chatContainerRef.current?.setInputValue(text); + setTimeout(() => { + chatContainerRef.current?.submit(); + }, 100); + }} + /> +