Skip to content

Commit

Permalink
feat: implement duplicate row functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
AliceLanniste committed Oct 29, 2024
1 parent 71d45b3 commit 06240f5
Show file tree
Hide file tree
Showing 11 changed files with 169 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}
}
26 changes: 26 additions & 0 deletions apps/nestjs-backend/test/record.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
deleteRecord,
deleteRecords,
permanentDeleteTable,
duplicateRecord,
getField,
getRecord,
getRecords,
Expand Down Expand Up @@ -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', () => {
Expand Down
22 changes: 22 additions & 0 deletions apps/nestjs-backend/test/utils/init-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import type {
ITableFullVo,
ICreateSpaceRo,
ICreateBaseRo,
IDuplicateVo,
IRecordInsertOrderRo,
} from '@teable/openapi';
import {
axios,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/common-i18n/src/locales/en/sdk.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
},
"expandRecord": {
"copy": "Copy to clipboard",
"duplicateRecord": "Duplicate record",
"copyRecordUrl": "Copy record URL",
"deleteRecord": "Delete record",
"recordHistory": {
Expand Down
1 change: 1 addition & 0 deletions packages/common-i18n/src/locales/zh/sdk.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@
},
"expandRecord": {
"copy": "复制到剪贴板",
"duplicateRecord": "复制记录",
"copyRecordUrl": "复制记录链接",
"deleteRecord": "删除记录",
"recordHistory": {
Expand Down
50 changes: 50 additions & 0 deletions packages/openapi/src/record/duplicate.ts
Original file line number Diff line number Diff line change
@@ -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<typeof duplicateVoSchema>;
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<IDuplicateVo>(urlBuilder(DUPLICATE_URL, { tableId, recordId }), order);
};
1 change: 1 addition & 0 deletions packages/openapi/src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
17 changes: 17 additions & 0 deletions packages/sdk/src/components/expand-record/ExpandRecord.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface IExpandRecordProps {
onRecordHistoryToggle?: () => void;
onCommentToggle?: () => void;
onDelete?: () => Promise<void>;
onDuplicate?: () => Promise<void>;
}

export const ExpandRecord = (props: IExpandRecordProps) => {
Expand All @@ -55,6 +56,7 @@ export const ExpandRecord = (props: IExpandRecordProps) => {
onRecordHistoryToggle,
onCommentToggle,
onDelete,
onDuplicate,
} = props;
const views = useViews() as (GridView | undefined)[];
const tableId = useTableId();
Expand Down Expand Up @@ -150,6 +152,21 @@ export const ExpandRecord = (props: IExpandRecordProps) => {
tableId={tableId}
/>
)}
<ExpandRecordHeader
title={record?.name}
recordHistoryVisible={recordHistoryVisible}
disabledPrev={disabledPrev}
disabledNext={disabledNext}
onClose={onClose}
onPrev={onPrevInner}
onNext={onNextInner}
onCopyUrl={onCopyUrl}
onRecordHistoryToggle={onRecordHistoryToggle}
onDelete={onDelete}
onDuplicate={onDuplicate}
tableId={tableId}
recordId={recordId}
/>
<div className="relative flex flex-1 overflow-hidden">
{recordHistoryVisible ? (
<div className="flex size-full overflow-hidden rounded-b bg-background">
Expand Down
12 changes: 12 additions & 0 deletions packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ChevronDown,
ChevronUp,
Copy,
History,
Link,
MoreHorizontal,
Expand Down Expand Up @@ -38,6 +39,7 @@ interface IExpandRecordHeader {
onRecordHistoryToggle?: () => void;
onCommentToggle?: () => void;
onDelete?: () => Promise<void>;
onDuplicate?: () => Promise<void>;
}

// eslint-disable-next-line @typescript-eslint/naming-convention
Expand All @@ -61,6 +63,7 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => {
onRecordHistoryToggle,
onCommentToggle,
onDelete,
onDuplicate,
} = props;

const permission = useTablePermission();
Expand Down Expand Up @@ -162,6 +165,15 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => {
<MoreHorizontal />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 px-4 py-2 text-sm outline-none"
onClick={async () => {
await onDuplicate?.();
onClose?.();
}}
>
<Copy /> {t('expandRecord.duplicateRecord')}
</DropdownMenuItem>
<DropdownMenuItem
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"
onClick={async () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/sdk/src/components/expand-record/ExpandRecorder.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,6 +47,7 @@ export const ExpandRecorder = (props: IExpandRecorderProps) => {
onClose,
onUpdateRecordIdCallback,
commentId,
viewId,
} = props;
const { toast } = useToast();
const { t } = useTranslation();
Expand All @@ -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);
};
Expand Down Expand Up @@ -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 () => {
Expand Down

0 comments on commit 06240f5

Please sign in to comment.