Skip to content

Commit e93dc65

Browse files
authored
feat: record menu supports duplicate record (#1060)
* feat: record menu supports duplicate record * fix: the display of card covers * fix: linting types * fix: the click penetration of dialog
1 parent d9e5593 commit e93dc65

26 files changed

Lines changed: 183 additions & 164 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,12 @@ export class RecordOpenApiController {
143143

144144
@Permissions('record|create')
145145
@Post(':recordId')
146-
async duplicateRecords(
146+
async duplicateRecord(
147147
@Param('tableId') tableId: string,
148148
@Param('recordId') recordId: string,
149149
@Body(new ZodValidationPipe(recordInsertOrderRoSchema)) order: IRecordInsertOrderRo
150150
) {
151-
return await this.recordOpenApiService.duplicateRecords(tableId, recordId, order);
151+
return await this.recordOpenApiService.duplicateRecord(tableId, recordId, order);
152152
}
153153

154154
@Permissions('record|delete')

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ export class RecordOpenApiService {
573573
return await this.updateRecord(tableId, recordId, updateRecordRo);
574574
}
575575

576-
async duplicateRecords(tableId: string, recordId: string, order: IRecordInsertOrderRo) {
576+
async duplicateRecord(tableId: string, recordId: string, order: IRecordInsertOrderRo) {
577577
const query = { fieldKeyType: FieldKeyType.Id };
578578
const result = await this.recordService.getRecord(tableId, recordId, query);
579579
const records = { fields: result.fields };

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
createField as apiCreateField,
4242
deleteField as apiDeleteField,
4343
convertField as apiConvertField,
44-
duplicateRecords as apiDuplicateRecords,
44+
duplicateRecord as apiDuplicateRecord,
4545
getFields as apiGetFields,
4646
getField as apiGetField,
4747
getViewList as apiGetViewList,
@@ -324,7 +324,7 @@ export async function duplicateRecord(
324324
expectStatus = 201
325325
) {
326326
try {
327-
const res = await apiDuplicateRecords(tableId, recordId, order);
327+
const res = await apiDuplicateRecord(tableId, recordId, order);
328328

329329
expect(res.status).toEqual(expectStatus);
330330
return res.data;

apps/nextjs-app/src/features/app/blocks/view/gallery/components/Card.tsx

Lines changed: 14 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
11
/* eslint-disable jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events */
22
import type { IAttachmentCellValue } from '@teable/core';
33
import { FieldKeyType } from '@teable/core';
4-
import { ArrowDown, ArrowUp, Image, Maximize2, Trash } from '@teable/icons';
4+
import { ArrowDown, ArrowUp, Copy, Image, Maximize2, Trash } from '@teable/icons';
55
import type { IRecordInsertOrderRo } from '@teable/openapi';
6-
import { createRecords, deleteRecord } from '@teable/openapi';
7-
import { CellValue, getFileCover } from '@teable/sdk/components';
8-
import { useAttachmentPreviewI18Map } from '@teable/sdk/components/hooks';
6+
import { createRecords, deleteRecord, duplicateRecord } from '@teable/openapi';
7+
import { CellValue } from '@teable/sdk/components';
98
import { useFieldStaticGetter, useTableId, useViewId } from '@teable/sdk/hooks';
109
import type { Record } from '@teable/sdk/model';
11-
import { FilePreviewItem, FilePreviewProvider } from '@teable/ui-lib/base';
1210
import {
13-
Carousel,
14-
CarouselContent,
15-
CarouselItem,
16-
CarouselNext,
17-
CarouselPrevious,
1811
ContextMenu,
1912
ContextMenuContent,
2013
ContextMenuItem,
@@ -26,6 +19,7 @@ import { useTranslation } from 'react-i18next';
2619
import { tableConfig } from '@/features/i18n/table.config';
2720
import { useGallery } from '../hooks';
2821
import { CARD_COVER_HEIGHT, CARD_STYLE } from '../utils';
22+
import { CardCarousel } from './CardCarousel';
2923

3024
interface IKanbanCardProps {
3125
card: Record;
@@ -36,7 +30,6 @@ export const Card = (props: IKanbanCardProps) => {
3630
const tableId = useTableId();
3731
const viewId = useViewId();
3832
const getFieldStatic = useFieldStaticGetter();
39-
const i18nMap = useAttachmentPreviewI18Map();
4033
const { t } = useTranslation(tableConfig.i18nNamespaces);
4134
const {
4235
coverField,
@@ -70,6 +63,11 @@ export const Card = (props: IKanbanCardProps) => {
7063
deleteRecord(tableId, card.id);
7164
};
7265

66+
const onDuplicate = () => {
67+
if (tableId == null || viewId == null) return;
68+
duplicateRecord(tableId, card.id, { viewId, anchorId: card.id, position: 'after' });
69+
};
70+
7371
const onInsert = async (position: IRecordInsertOrderRo['position']) => {
7472
if (tableId == null || viewId == null) return;
7573
const res = await createRecords(tableId, {
@@ -98,61 +96,7 @@ export const Card = (props: IKanbanCardProps) => {
9896
{coverFieldId && (
9997
<Fragment>
10098
{coverCellValue?.length ? (
101-
<FilePreviewProvider i18nMap={i18nMap}>
102-
<Carousel
103-
opts={{
104-
watchDrag: false,
105-
watchResize: false,
106-
watchSlides: false,
107-
}}
108-
className="border-b"
109-
>
110-
<CarouselContent className="ml-0">
111-
{coverCellValue.map(
112-
({ id, name, size, mimetype, presignedUrl, lgThumbnailUrl }) => {
113-
const url = lgThumbnailUrl ?? getFileCover(mimetype, presignedUrl);
114-
return (
115-
<CarouselItem
116-
key={id}
117-
style={{ height: CARD_COVER_HEIGHT }}
118-
className="relative size-full pl-0"
119-
>
120-
<FilePreviewItem
121-
key={id}
122-
className="size-full cursor-pointer"
123-
src={presignedUrl || ''}
124-
name={name}
125-
mimetype={mimetype}
126-
size={size}
127-
>
128-
<img
129-
src={url}
130-
alt="card cover"
131-
className="size-full"
132-
style={{
133-
objectFit: isCoverFit ? 'contain' : 'cover',
134-
}}
135-
/>
136-
</FilePreviewItem>
137-
</CarouselItem>
138-
);
139-
}
140-
)}
141-
</CarouselContent>
142-
{coverCellValue?.length > 1 && (
143-
<Fragment>
144-
<CarouselPrevious
145-
className="left-1 size-7"
146-
onClick={(e) => e.stopPropagation()}
147-
/>
148-
<CarouselNext
149-
className="right-1 size-7"
150-
onClick={(e) => e.stopPropagation()}
151-
/>
152-
</Fragment>
153-
)}
154-
</Carousel>
155-
</FilePreviewProvider>
99+
<CardCarousel value={coverCellValue} isCoverFit={isCoverFit} />
156100
) : (
157101
<div
158102
style={{ height: CARD_COVER_HEIGHT }}
@@ -204,6 +148,10 @@ export const Card = (props: IKanbanCardProps) => {
204148
{t('table:kanban.cardMenu.insertCardBelow')}
205149
</ContextMenuItem>
206150
<ContextMenuSeparator />
151+
<ContextMenuItem onClick={onDuplicate}>
152+
<Copy className="mr-2 size-4" />
153+
{t('table:kanban.cardMenu.duplicateCard')}
154+
</ContextMenuItem>
207155
</>
208156
)}
209157
<ContextMenuItem onClick={onExpand}>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { IAttachmentCellValue } from '@teable/core';
2+
import { isSystemFileIcon, getFileCover } from '@teable/sdk/components';
3+
import { useAttachmentPreviewI18Map } from '@teable/sdk/components/hooks';
4+
import { FilePreviewProvider, FilePreviewItem } from '@teable/ui-lib/base';
5+
import {
6+
Carousel,
7+
CarouselContent,
8+
CarouselItem,
9+
CarouselPrevious,
10+
CarouselNext,
11+
} from '@teable/ui-lib/shadcn';
12+
import { Fragment } from 'react';
13+
import { CARD_COVER_HEIGHT } from '../utils';
14+
15+
interface ICardCarouselProps {
16+
value: IAttachmentCellValue;
17+
isCoverFit?: boolean;
18+
}
19+
20+
export const CardCarousel = (props: ICardCarouselProps) => {
21+
const { value, isCoverFit } = props;
22+
const i18nMap = useAttachmentPreviewI18Map();
23+
24+
return (
25+
<FilePreviewProvider i18nMap={i18nMap}>
26+
<Carousel
27+
opts={{
28+
watchDrag: false,
29+
watchResize: false,
30+
watchSlides: false,
31+
}}
32+
className="border-b"
33+
>
34+
<CarouselContent className="ml-0">
35+
{value.map(({ id, name, size, mimetype, presignedUrl, lgThumbnailUrl }) => {
36+
const isSystemFile = isSystemFileIcon(mimetype);
37+
const url = lgThumbnailUrl ?? getFileCover(mimetype, presignedUrl);
38+
return (
39+
<CarouselItem
40+
key={id}
41+
style={{ height: CARD_COVER_HEIGHT }}
42+
className="relative size-full pl-0"
43+
>
44+
<FilePreviewItem
45+
key={id}
46+
className="flex size-full cursor-pointer items-center justify-center"
47+
src={presignedUrl || ''}
48+
name={name}
49+
mimetype={mimetype}
50+
size={size}
51+
>
52+
<img
53+
src={url}
54+
alt="card cover"
55+
className={isSystemFile ? 'size-20' : 'size-full'}
56+
style={{
57+
objectFit: isCoverFit ? 'contain' : 'cover',
58+
}}
59+
/>
60+
</FilePreviewItem>
61+
</CarouselItem>
62+
);
63+
})}
64+
</CarouselContent>
65+
{value.length > 1 && (
66+
<Fragment>
67+
<CarouselPrevious className="left-1 size-7" onClick={(e) => e.stopPropagation()} />
68+
<CarouselNext className="right-1 size-7" onClick={(e) => e.stopPropagation()} />
69+
</Fragment>
70+
)}
71+
</Carousel>
72+
</FilePreviewProvider>
73+
);
74+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './Card';
2+
export * from './CardCarousel';

apps/nextjs-app/src/features/app/blocks/view/gallery/context/GalleryProvider.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const GalleryProvider = ({ children }: { children: ReactNode }) => {
8686
{tableId && (
8787
<ExpandRecorder
8888
tableId={tableId}
89+
viewId={view?.id}
8990
recordId={expandRecordId}
9091
recordIds={expandRecordId ? [expandRecordId] : []}
9192
onClose={() => setExpandRecordId(undefined)}

apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,14 @@ export const GridViewBaseInner: React.FC<IGridViewBaseInnerProps> = (
329329
) ?? {};
330330
generateRecord(fieldValueMap, Math.max(targetIndex, 0), { anchorId, position }, num);
331331
},
332+
duplicateRecord: async () => {
333+
if (!record || !activeViewId) return;
334+
await Record.duplicateRecord(tableId, record.id, {
335+
viewId: activeViewId,
336+
anchorId: record.id,
337+
position: 'after',
338+
});
339+
},
332340
deleteRecords: async (selection) => {
333341
deleteRecords(selection);
334342
gridRef.current?.setSelection(emptySelection);
@@ -887,6 +895,7 @@ export const GridViewBaseInner: React.FC<IGridViewBaseInnerProps> = (
887895
{expandRecord != null && (
888896
<ExpandRecorder
889897
tableId={expandRecord.tableId}
898+
viewId={activeViewId}
890899
recordId={expandRecord.recordId}
891900
recordIds={[expandRecord.recordId]}
892901
onClose={() => setExpandRecord(undefined)}

apps/nextjs-app/src/features/app/blocks/view/grid/components/RecordMenu.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Trash, ArrowUp, ArrowDown } from '@teable/icons';
1+
import { Trash, ArrowUp, ArrowDown, Copy } from '@teable/icons';
22
import { useGridViewStore } from '@teable/sdk/components';
33
import { useTableId, useTablePermission, useView } from '@teable/sdk/hooks';
44
import {
@@ -43,6 +43,7 @@ enum MenuItemType {
4343
Delete = 'Delete',
4444
InsertAbove = 'InsertAbove',
4545
InsertBelow = 'InsertBelow',
46+
Duplicate = 'Duplicate',
4647
}
4748

4849
const iconClassName = 'mr-2 h-4 w-4 shrink-0';
@@ -179,6 +180,19 @@ export const RecordMenu = () => {
179180
},
180181
},
181182
],
183+
[
184+
{
185+
type: MenuItemType.Duplicate,
186+
name: t('sdk:expandRecord.duplicateRecord'),
187+
icon: <Copy className={iconClassName} />,
188+
hidden: isMultipleSelected || !permission['record|create'],
189+
onClick: async () => {
190+
if (tableId && recordMenu?.duplicateRecord) {
191+
await recordMenu.duplicateRecord();
192+
}
193+
},
194+
},
195+
],
182196
[
183197
{
184198
type: MenuItemType.Delete,

0 commit comments

Comments
 (0)