Skip to content

Commit fe5151f

Browse files
feat: implement duplicate row functionality (#930)
* feat: implement duplicate row functionality * fix: merge error
1 parent 15d74ef commit fe5151f

File tree

11 files changed

+154
-1
lines changed

11 files changed

+154
-1
lines changed

apps/nestjs-backend/src/features/record/open-api/record-open-api.controller.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
IGetRecordHistoryQuery,
3030
updateRecordsRoSchema,
3131
IUpdateRecordsRo,
32+
recordInsertOrderRoSchema,
33+
IRecordInsertOrderRo,
3234
} from '@teable/openapi';
3335
import { EmitControllerEvent } from '../../../event-emitter/decorators/emit-controller-event.decorator';
3436
import { Events } from '../../../event-emitter/events';
@@ -139,6 +141,16 @@ export class RecordOpenApiController {
139141
return await this.recordOpenApiService.multipleCreateRecords(tableId, createRecordsRo);
140142
}
141143

144+
@Permissions('record|create')
145+
@Post(':recordId')
146+
async duplicateRecords(
147+
@Param('tableId') tableId: string,
148+
@Param('recordId') recordId: string,
149+
@Body(new ZodValidationPipe(recordInsertOrderRoSchema)) order: IRecordInsertOrderRo
150+
) {
151+
return await this.recordOpenApiService.duplicateRecords(tableId, recordId, order);
152+
}
153+
142154
@Permissions('record|delete')
143155
@Delete(':recordId')
144156
async deleteRecord(

apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,4 +572,19 @@ export class RecordOpenApiService {
572572

573573
return await this.updateRecord(tableId, recordId, updateRecordRo);
574574
}
575+
576+
async duplicateRecords(tableId: string, recordId: string, order: IRecordInsertOrderRo) {
577+
const query = { fieldKeyType: FieldKeyType.Id };
578+
const result = await this.recordService.getRecord(tableId, recordId, query);
579+
const records = { fields: result.fields };
580+
const createRecordsRo = {
581+
fieldKeyType: FieldKeyType.Id,
582+
order,
583+
records: [records],
584+
};
585+
const createdRecords = await this.prismaService.$tx(async () =>
586+
this.createRecords(tableId, createRecordsRo)
587+
);
588+
return { ids: createdRecords.records.map((record) => record.id) };
589+
}
575590
}

apps/nestjs-backend/test/record.e2e-spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
deleteRecord,
1313
deleteRecords,
1414
permanentDeleteTable,
15+
duplicateRecord,
1516
getField,
1617
getRecord,
1718
getRecords,
@@ -280,6 +281,31 @@ describe('OpenAPI RecordController (e2e)', () => {
280281
],
281282
});
282283
});
284+
285+
it('should duplicate a record', async () => {
286+
const value1 = 'New Record';
287+
const addRecordRes = await createRecords(table.id, {
288+
fieldKeyType: FieldKeyType.Id,
289+
records: [
290+
{
291+
fields: {
292+
[table.fields[0].id]: value1,
293+
},
294+
},
295+
],
296+
});
297+
const addRecord = await getRecord(table.id, addRecordRes.records[0].id, undefined, 200);
298+
expect(addRecord.fields[table.fields[0].id]).toEqual(value1);
299+
300+
const viewId = table.views[0].id;
301+
const duplicateRes = await duplicateRecord(table.id, addRecord.id, {
302+
viewId,
303+
anchorId: addRecord.id,
304+
position: 'after',
305+
});
306+
const record = await getRecord(table.id, duplicateRes.ids[0], undefined, 200);
307+
expect(record.fields[table.fields[0].id]).toEqual(value1);
308+
});
283309
});
284310

285311
describe('validate record value by field validation', () => {

apps/nestjs-backend/test/utils/init-app.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import type {
2626
ITableFullVo,
2727
ICreateSpaceRo,
2828
ICreateBaseRo,
29+
IDuplicateVo,
30+
IRecordInsertOrderRo,
2931
} from '@teable/openapi';
3032
import {
3133
axios,
@@ -39,6 +41,7 @@ import {
3941
createField as apiCreateField,
4042
deleteField as apiDeleteField,
4143
convertField as apiConvertField,
44+
duplicateRecords as apiDuplicateRecords,
4245
getFields as apiGetFields,
4346
getField as apiGetField,
4447
getViewList as apiGetViewList,
@@ -314,6 +317,25 @@ export async function getRecords(tableId: string, query?: IGetRecordsRo): Promis
314317
return result.data;
315318
}
316319

320+
export async function duplicateRecord(
321+
tableId: string,
322+
recordId: string,
323+
order: IRecordInsertOrderRo,
324+
expectStatus = 201
325+
) {
326+
try {
327+
const res = await apiDuplicateRecords(tableId, recordId, order);
328+
329+
expect(res.status).toEqual(expectStatus);
330+
return res.data;
331+
} catch (e: unknown) {
332+
if ((e as HttpError).status !== expectStatus) {
333+
throw e;
334+
}
335+
return {} as IDuplicateVo;
336+
}
337+
}
338+
317339
export async function createRecords(
318340
tableId: string,
319341
recordsRo: ICreateRecordsRo,

packages/common-i18n/src/locales/en/sdk.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
},
180180
"expandRecord": {
181181
"copy": "Copy to clipboard",
182+
"duplicateRecord": "Duplicate record",
182183
"copyRecordUrl": "Copy record URL",
183184
"deleteRecord": "Delete record",
184185
"recordHistory": {

packages/common-i18n/src/locales/zh/sdk.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@
193193
},
194194
"expandRecord": {
195195
"copy": "复制到剪贴板",
196+
"duplicateRecord": "复制记录",
196197
"copyRecordUrl": "复制记录链接",
197198
"deleteRecord": "删除记录",
198199
"recordHistory": {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { axios } from '../axios';
2+
import { registerRoute, urlBuilder } from '../utils';
3+
import { z } from '../zod';
4+
import type { IRecordInsertOrderRo } from './create';
5+
import { recordInsertOrderRoSchema } from './create';
6+
7+
export const DUPLICATE_URL = '/table/{tableId}/record/{recordId}';
8+
9+
export const duplicateVoSchema = z.object({
10+
ids: z.array(z.string()),
11+
});
12+
13+
export type IDuplicateVo = z.infer<typeof duplicateVoSchema>;
14+
export const duplicateRoute = registerRoute({
15+
method: 'post',
16+
path: DUPLICATE_URL,
17+
description: 'Duplicate the selected data',
18+
request: {
19+
params: z.object({
20+
tableId: z.string(),
21+
recordId: z.string(),
22+
}),
23+
body: {
24+
content: {
25+
'application/json': {
26+
schema: recordInsertOrderRoSchema.optional(),
27+
},
28+
},
29+
},
30+
},
31+
responses: {
32+
201: {
33+
description: 'Successful duplicate',
34+
content: {
35+
'application/json': {
36+
schema: duplicateVoSchema,
37+
},
38+
},
39+
},
40+
},
41+
tags: ['record'],
42+
});
43+
44+
export const duplicateRecords = async (
45+
tableId: string,
46+
recordId: string,
47+
order: IRecordInsertOrderRo
48+
) => {
49+
return axios.post<IDuplicateVo>(urlBuilder(DUPLICATE_URL, { tableId, recordId }), order);
50+
};

packages/openapi/src/record/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './create';
44
export * from './update';
55
export * from './update-records';
66
export * from './delete';
7+
export * from './duplicate';
78
export * from './delete-list';
89
export * from './get-record-history';
910
export * from './get-record-list-history';

packages/sdk/src/components/expand-record/ExpandRecord.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface IExpandRecordProps {
3636
onRecordHistoryToggle?: () => void;
3737
onCommentToggle?: () => void;
3838
onDelete?: () => Promise<void>;
39+
onDuplicate?: () => Promise<void>;
3940
}
4041

4142
export const ExpandRecord = (props: IExpandRecordProps) => {
@@ -55,6 +56,7 @@ export const ExpandRecord = (props: IExpandRecordProps) => {
5556
onRecordHistoryToggle,
5657
onCommentToggle,
5758
onDelete,
59+
onDuplicate,
5860
} = props;
5961
const views = useViews() as (GridView | undefined)[];
6062
const tableId = useTableId();

packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
ChevronDown,
33
ChevronUp,
4+
Copy,
45
History,
56
Link,
67
MoreHorizontal,
@@ -38,6 +39,7 @@ interface IExpandRecordHeader {
3839
onRecordHistoryToggle?: () => void;
3940
onCommentToggle?: () => void;
4041
onDelete?: () => Promise<void>;
42+
onDuplicate?: () => Promise<void>;
4143
}
4244

4345
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -61,6 +63,7 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => {
6163
onRecordHistoryToggle,
6264
onCommentToggle,
6365
onDelete,
66+
onDuplicate,
6467
} = props;
6568

6669
const permission = useTablePermission();
@@ -162,6 +165,15 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => {
162165
<MoreHorizontal />
163166
</DropdownMenuTrigger>
164167
<DropdownMenuContent>
168+
<DropdownMenuItem
169+
className="flex cursor-pointer items-center gap-2 px-4 py-2 text-sm outline-none"
170+
onClick={async () => {
171+
await onDuplicate?.();
172+
onClose?.();
173+
}}
174+
>
175+
<Copy /> {t('expandRecord.duplicateRecord')}
176+
</DropdownMenuItem>
165177
<DropdownMenuItem
166178
className="flex cursor-pointer items-center gap-2 px-4 py-2 text-sm text-red-500 outline-none hover:text-red-500 focus:text-red-500 aria-selected:text-red-500"
167179
onClick={async () => {

0 commit comments

Comments
 (0)