Skip to content
10 changes: 6 additions & 4 deletions apps/nestjs-backend/src/features/base/base-duplicate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 };
}
Expand Down
14 changes: 12 additions & 2 deletions apps/nestjs-backend/src/features/base/base-import.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions apps/nestjs-backend/src/features/base/base.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: {
Expand All @@ -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) {
Expand All @@ -309,6 +331,7 @@ export class BaseService {
fromBaseId,
spaceId,
withRecords,
baseId,
});
await this.prismaService.template.update({
where: { id: templateId },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
112 changes: 112 additions & 0 deletions apps/nestjs-backend/test/template.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand Down Expand Up @@ -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);
});
});
});
72 changes: 72 additions & 0 deletions apps/nextjs-app/src/features/app/base/ChatPage/ChatPage.tsx
Original file line number Diff line number Diff line change
@@ -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<ChatContainerRef>(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 (
<div className="flex size-full flex-col overflow-auto">
<div className="mt-8 flex flex-col justify-center gap-4 py-16 text-center">
<h1 className={cn('px-4 text-3xl font-bold mt-8 lg:text-5xl md:text-5xl sm:text-4xl')}>
{t('template.aiTitle')}
</h1>

<p className="px-6">{t('template.aiSubTitle')}</p>
</div>

<div className="flex h-full flex-col">
<PromptBox
onEnter={(text) => {
chatContainerRef.current?.setInputValue(text);
setTimeout(() => {
chatContainerRef.current?.submit();
}, 100);
}}
/>
<Template initialTemplates={TemplateList || []} categories={TemplateCategoryList || []} />
</div>

{baseId && (
<div
className={cn('fixed top-0 right-0 flex h-full bg-card overflow-hidden max-w-[60%]', {
'opacity-0 size-0': !isVisible,
'opacity-100 z-50': isVisible,
})}
>
<PanelContainer ref={chatContainerRef} baseId={baseId} maxWidth="100%" />
</div>
)}
</div>
);
};
Loading
Loading