diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts index d5f6507d4..82cf6308a 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts @@ -29,6 +29,8 @@ import { IGetRecordHistoryQuery, updateRecordsRoSchema, IUpdateRecordsRo, + recordInsertOrderRoSchema, + IRecordInsertOrderRo, } from '@teable/openapi'; import { EmitControllerEvent } from '../../../event-emitter/decorators/emit-controller-event.decorator'; import { Events } from '../../../event-emitter/events'; @@ -139,6 +141,16 @@ export class RecordOpenApiController { return await this.recordOpenApiService.multipleCreateRecords(tableId, createRecordsRo); } + @Permissions('record|create') + @Post(':recordId') + async duplicateRecords( + @Param('tableId') tableId: string, + @Param('recordId') recordId: string, + @Body(new ZodValidationPipe(recordInsertOrderRoSchema)) order: IRecordInsertOrderRo + ) { + return await this.recordOpenApiService.duplicateRecords(tableId, recordId, order); + } + @Permissions('record|delete') @Delete(':recordId') async deleteRecord( diff --git a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts index b793f21e7..e571616bc 100644 --- a/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts +++ b/apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts @@ -572,4 +572,19 @@ export class RecordOpenApiService { return await this.updateRecord(tableId, recordId, updateRecordRo); } + + async duplicateRecords(tableId: string, recordId: string, order: IRecordInsertOrderRo) { + const query = { fieldKeyType: FieldKeyType.Id }; + const result = await this.recordService.getRecord(tableId, recordId, query); + const records = { fields: result.fields }; + const createRecordsRo = { + fieldKeyType: FieldKeyType.Id, + order, + records: [records], + }; + const createdRecords = await this.prismaService.$tx(async () => + this.createRecords(tableId, createRecordsRo) + ); + return { ids: createdRecords.records.map((record) => record.id) }; + } } diff --git a/apps/nestjs-backend/test/record.e2e-spec.ts b/apps/nestjs-backend/test/record.e2e-spec.ts index 17eb287bb..ed78c01ed 100644 --- a/apps/nestjs-backend/test/record.e2e-spec.ts +++ b/apps/nestjs-backend/test/record.e2e-spec.ts @@ -12,6 +12,7 @@ import { deleteRecord, deleteRecords, permanentDeleteTable, + duplicateRecord, getField, getRecord, getRecords, @@ -280,6 +281,31 @@ describe('OpenAPI RecordController (e2e)', () => { ], }); }); + + it('should duplicate a record', async () => { + const value1 = 'New Record'; + const addRecordRes = await createRecords(table.id, { + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [table.fields[0].id]: value1, + }, + }, + ], + }); + const addRecord = await getRecord(table.id, addRecordRes.records[0].id, undefined, 200); + expect(addRecord.fields[table.fields[0].id]).toEqual(value1); + + const viewId = table.views[0].id; + const duplicateRes = await duplicateRecord(table.id, addRecord.id, { + viewId, + anchorId: addRecord.id, + position: 'after', + }); + const record = await getRecord(table.id, duplicateRes.ids[0], undefined, 200); + expect(record.fields[table.fields[0].id]).toEqual(value1); + }); }); describe('validate record value by field validation', () => { diff --git a/apps/nestjs-backend/test/utils/init-app.ts b/apps/nestjs-backend/test/utils/init-app.ts index a42f0f5ac..42a097890 100644 --- a/apps/nestjs-backend/test/utils/init-app.ts +++ b/apps/nestjs-backend/test/utils/init-app.ts @@ -26,6 +26,8 @@ import type { ITableFullVo, ICreateSpaceRo, ICreateBaseRo, + IDuplicateVo, + IRecordInsertOrderRo, } from '@teable/openapi'; import { axios, @@ -39,6 +41,7 @@ import { createField as apiCreateField, deleteField as apiDeleteField, convertField as apiConvertField, + duplicateRecords as apiDuplicateRecords, getFields as apiGetFields, getField as apiGetField, getViewList as apiGetViewList, @@ -314,6 +317,25 @@ export async function getRecords(tableId: string, query?: IGetRecordsRo): Promis return result.data; } +export async function duplicateRecord( + tableId: string, + recordId: string, + order: IRecordInsertOrderRo, + expectStatus = 201 +) { + try { + const res = await apiDuplicateRecords(tableId, recordId, order); + + expect(res.status).toEqual(expectStatus); + return res.data; + } catch (e: unknown) { + if ((e as HttpError).status !== expectStatus) { + throw e; + } + return {} as IDuplicateVo; + } +} + export async function createRecords( tableId: string, recordsRo: ICreateRecordsRo, diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index 503193506..48881fc69 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -179,6 +179,7 @@ }, "expandRecord": { "copy": "Copy to clipboard", + "duplicateRecord": "Duplicate record", "copyRecordUrl": "Copy record URL", "deleteRecord": "Delete record", "recordHistory": { diff --git a/packages/common-i18n/src/locales/zh/sdk.json b/packages/common-i18n/src/locales/zh/sdk.json index 7df0c8c57..972c25521 100644 --- a/packages/common-i18n/src/locales/zh/sdk.json +++ b/packages/common-i18n/src/locales/zh/sdk.json @@ -193,6 +193,7 @@ }, "expandRecord": { "copy": "复制到剪贴板", + "duplicateRecord": "复制记录", "copyRecordUrl": "复制记录链接", "deleteRecord": "删除记录", "recordHistory": { diff --git a/packages/openapi/src/record/duplicate.ts b/packages/openapi/src/record/duplicate.ts new file mode 100644 index 000000000..e2df26fa5 --- /dev/null +++ b/packages/openapi/src/record/duplicate.ts @@ -0,0 +1,50 @@ +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import type { IRecordInsertOrderRo } from './create'; +import { recordInsertOrderRoSchema } from './create'; + +export const DUPLICATE_URL = '/table/{tableId}/record/{recordId}'; + +export const duplicateVoSchema = z.object({ + ids: z.array(z.string()), +}); + +export type IDuplicateVo = z.infer; +export const duplicateRoute = registerRoute({ + method: 'post', + path: DUPLICATE_URL, + description: 'Duplicate the selected data', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: recordInsertOrderRoSchema.optional(), + }, + }, + }, + }, + responses: { + 201: { + description: 'Successful duplicate', + content: { + 'application/json': { + schema: duplicateVoSchema, + }, + }, + }, + }, + tags: ['record'], +}); + +export const duplicateRecords = async ( + tableId: string, + recordId: string, + order: IRecordInsertOrderRo +) => { + return axios.post(urlBuilder(DUPLICATE_URL, { tableId, recordId }), order); +}; diff --git a/packages/openapi/src/record/index.ts b/packages/openapi/src/record/index.ts index ba63be4ae..e55c5714b 100644 --- a/packages/openapi/src/record/index.ts +++ b/packages/openapi/src/record/index.ts @@ -4,6 +4,7 @@ export * from './create'; export * from './update'; export * from './update-records'; export * from './delete'; +export * from './duplicate'; export * from './delete-list'; export * from './get-record-history'; export * from './get-record-list-history'; diff --git a/packages/sdk/src/components/expand-record/ExpandRecord.tsx b/packages/sdk/src/components/expand-record/ExpandRecord.tsx index 8e0150b4c..c9f507a7a 100644 --- a/packages/sdk/src/components/expand-record/ExpandRecord.tsx +++ b/packages/sdk/src/components/expand-record/ExpandRecord.tsx @@ -36,6 +36,7 @@ interface IExpandRecordProps { onRecordHistoryToggle?: () => void; onCommentToggle?: () => void; onDelete?: () => Promise; + onDuplicate?: () => Promise; } export const ExpandRecord = (props: IExpandRecordProps) => { @@ -55,6 +56,7 @@ export const ExpandRecord = (props: IExpandRecordProps) => { onRecordHistoryToggle, onCommentToggle, onDelete, + onDuplicate, } = props; const views = useViews() as (GridView | undefined)[]; const tableId = useTableId(); diff --git a/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx b/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx index addcb402e..bd133bb20 100644 --- a/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx +++ b/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx @@ -1,6 +1,7 @@ import { ChevronDown, ChevronUp, + Copy, History, Link, MoreHorizontal, @@ -38,6 +39,7 @@ interface IExpandRecordHeader { onRecordHistoryToggle?: () => void; onCommentToggle?: () => void; onDelete?: () => Promise; + onDuplicate?: () => Promise; } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -61,6 +63,7 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => { onRecordHistoryToggle, onCommentToggle, onDelete, + onDuplicate, } = props; const permission = useTablePermission(); @@ -162,6 +165,15 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => { + { + await onDuplicate?.(); + onClose?.(); + }} + > + {t('expandRecord.duplicateRecord')} + { diff --git a/packages/sdk/src/components/expand-record/ExpandRecorder.tsx b/packages/sdk/src/components/expand-record/ExpandRecorder.tsx index f89c53eac..be3a36d6a 100644 --- a/packages/sdk/src/components/expand-record/ExpandRecorder.tsx +++ b/packages/sdk/src/components/expand-record/ExpandRecorder.tsx @@ -1,5 +1,5 @@ import type { IRecord } from '@teable/core'; -import { deleteRecord } from '@teable/openapi'; +import { deleteRecord, duplicateRecords } from '@teable/openapi'; import { useToast } from '@teable/ui-lib'; import { useEffect, type FC, type PropsWithChildren } from 'react'; import { useLocalStorage } from 'react-use'; @@ -47,6 +47,7 @@ export const ExpandRecorder = (props: IExpandRecorderProps) => { onClose, onUpdateRecordIdCallback, commentId, + viewId, } = props; const { toast } = useToast(); const { t } = useTranslation(); @@ -72,6 +73,15 @@ export const ExpandRecorder = (props: IExpandRecorderProps) => { return <>; } + const onDuplicate = async (tableId: string, recordId: string) => { + await duplicateRecords(tableId, recordId, { + viewId: viewId || '', + anchorId: recordId, + position: 'after', + }); + toast({ description: t('expandRecord.duplicateRecord') }); + }; + const updateCurrentRecordId = (recordId: string) => { onUpdateRecordIdCallback?.(recordId); }; @@ -108,6 +118,7 @@ export const ExpandRecorder = (props: IExpandRecorderProps) => { onPrev={updateCurrentRecordId} onNext={updateCurrentRecordId} onCopyUrl={onCopyUrl} + onDuplicate={async () => await onDuplicate(tableId, recordId)} onRecordHistoryToggle={onRecordHistoryToggle} onCommentToggle={onCommentToggle} onDelete={async () => {