Skip to content

Commit

Permalink
feat: record menu supports duplicate record (#1060)
Browse files Browse the repository at this point in the history
* feat: record menu supports duplicate record

* fix: the display of card covers

* fix: linting types

* fix: the click penetration of dialog
  • Loading branch information
Sky-FE authored Nov 6, 2024
1 parent d9e5593 commit e93dc65
Show file tree
Hide file tree
Showing 26 changed files with 183 additions and 164 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,12 @@ export class RecordOpenApiController {

@Permissions('record|create')
@Post(':recordId')
async duplicateRecords(
async duplicateRecord(
@Param('tableId') tableId: string,
@Param('recordId') recordId: string,
@Body(new ZodValidationPipe(recordInsertOrderRoSchema)) order: IRecordInsertOrderRo
) {
return await this.recordOpenApiService.duplicateRecords(tableId, recordId, order);
return await this.recordOpenApiService.duplicateRecord(tableId, recordId, order);
}

@Permissions('record|delete')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ export class RecordOpenApiService {
return await this.updateRecord(tableId, recordId, updateRecordRo);
}

async duplicateRecords(tableId: string, recordId: string, order: IRecordInsertOrderRo) {
async duplicateRecord(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 };
Expand Down
4 changes: 2 additions & 2 deletions apps/nestjs-backend/test/utils/init-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
createField as apiCreateField,
deleteField as apiDeleteField,
convertField as apiConvertField,
duplicateRecords as apiDuplicateRecords,
duplicateRecord as apiDuplicateRecord,
getFields as apiGetFields,
getField as apiGetField,
getViewList as apiGetViewList,
Expand Down Expand Up @@ -324,7 +324,7 @@ export async function duplicateRecord(
expectStatus = 201
) {
try {
const res = await apiDuplicateRecords(tableId, recordId, order);
const res = await apiDuplicateRecord(tableId, recordId, order);

expect(res.status).toEqual(expectStatus);
return res.data;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
/* eslint-disable jsx-a11y/no-static-element-interactions,jsx-a11y/click-events-have-key-events */
import type { IAttachmentCellValue } from '@teable/core';
import { FieldKeyType } from '@teable/core';
import { ArrowDown, ArrowUp, Image, Maximize2, Trash } from '@teable/icons';
import { ArrowDown, ArrowUp, Copy, Image, Maximize2, Trash } from '@teable/icons';
import type { IRecordInsertOrderRo } from '@teable/openapi';
import { createRecords, deleteRecord } from '@teable/openapi';
import { CellValue, getFileCover } from '@teable/sdk/components';
import { useAttachmentPreviewI18Map } from '@teable/sdk/components/hooks';
import { createRecords, deleteRecord, duplicateRecord } from '@teable/openapi';
import { CellValue } from '@teable/sdk/components';
import { useFieldStaticGetter, useTableId, useViewId } from '@teable/sdk/hooks';
import type { Record } from '@teable/sdk/model';
import { FilePreviewItem, FilePreviewProvider } from '@teable/ui-lib/base';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
Expand All @@ -26,6 +19,7 @@ import { useTranslation } from 'react-i18next';
import { tableConfig } from '@/features/i18n/table.config';
import { useGallery } from '../hooks';
import { CARD_COVER_HEIGHT, CARD_STYLE } from '../utils';
import { CardCarousel } from './CardCarousel';

interface IKanbanCardProps {
card: Record;
Expand All @@ -36,7 +30,6 @@ export const Card = (props: IKanbanCardProps) => {
const tableId = useTableId();
const viewId = useViewId();
const getFieldStatic = useFieldStaticGetter();
const i18nMap = useAttachmentPreviewI18Map();
const { t } = useTranslation(tableConfig.i18nNamespaces);
const {
coverField,
Expand Down Expand Up @@ -70,6 +63,11 @@ export const Card = (props: IKanbanCardProps) => {
deleteRecord(tableId, card.id);
};

const onDuplicate = () => {
if (tableId == null || viewId == null) return;
duplicateRecord(tableId, card.id, { viewId, anchorId: card.id, position: 'after' });
};

const onInsert = async (position: IRecordInsertOrderRo['position']) => {
if (tableId == null || viewId == null) return;
const res = await createRecords(tableId, {
Expand Down Expand Up @@ -98,61 +96,7 @@ export const Card = (props: IKanbanCardProps) => {
{coverFieldId && (
<Fragment>
{coverCellValue?.length ? (
<FilePreviewProvider i18nMap={i18nMap}>
<Carousel
opts={{
watchDrag: false,
watchResize: false,
watchSlides: false,
}}
className="border-b"
>
<CarouselContent className="ml-0">
{coverCellValue.map(
({ id, name, size, mimetype, presignedUrl, lgThumbnailUrl }) => {
const url = lgThumbnailUrl ?? getFileCover(mimetype, presignedUrl);
return (
<CarouselItem
key={id}
style={{ height: CARD_COVER_HEIGHT }}
className="relative size-full pl-0"
>
<FilePreviewItem
key={id}
className="size-full cursor-pointer"
src={presignedUrl || ''}
name={name}
mimetype={mimetype}
size={size}
>
<img
src={url}
alt="card cover"
className="size-full"
style={{
objectFit: isCoverFit ? 'contain' : 'cover',
}}
/>
</FilePreviewItem>
</CarouselItem>
);
}
)}
</CarouselContent>
{coverCellValue?.length > 1 && (
<Fragment>
<CarouselPrevious
className="left-1 size-7"
onClick={(e) => e.stopPropagation()}
/>
<CarouselNext
className="right-1 size-7"
onClick={(e) => e.stopPropagation()}
/>
</Fragment>
)}
</Carousel>
</FilePreviewProvider>
<CardCarousel value={coverCellValue} isCoverFit={isCoverFit} />
) : (
<div
style={{ height: CARD_COVER_HEIGHT }}
Expand Down Expand Up @@ -204,6 +148,10 @@ export const Card = (props: IKanbanCardProps) => {
{t('table:kanban.cardMenu.insertCardBelow')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onDuplicate}>
<Copy className="mr-2 size-4" />
{t('table:kanban.cardMenu.duplicateCard')}
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={onExpand}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { IAttachmentCellValue } from '@teable/core';
import { isSystemFileIcon, getFileCover } from '@teable/sdk/components';
import { useAttachmentPreviewI18Map } from '@teable/sdk/components/hooks';
import { FilePreviewProvider, FilePreviewItem } from '@teable/ui-lib/base';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
} from '@teable/ui-lib/shadcn';
import { Fragment } from 'react';
import { CARD_COVER_HEIGHT } from '../utils';

interface ICardCarouselProps {
value: IAttachmentCellValue;
isCoverFit?: boolean;
}

export const CardCarousel = (props: ICardCarouselProps) => {
const { value, isCoverFit } = props;
const i18nMap = useAttachmentPreviewI18Map();

return (
<FilePreviewProvider i18nMap={i18nMap}>
<Carousel
opts={{
watchDrag: false,
watchResize: false,
watchSlides: false,
}}
className="border-b"
>
<CarouselContent className="ml-0">
{value.map(({ id, name, size, mimetype, presignedUrl, lgThumbnailUrl }) => {
const isSystemFile = isSystemFileIcon(mimetype);
const url = lgThumbnailUrl ?? getFileCover(mimetype, presignedUrl);
return (
<CarouselItem
key={id}
style={{ height: CARD_COVER_HEIGHT }}
className="relative size-full pl-0"
>
<FilePreviewItem
key={id}
className="flex size-full cursor-pointer items-center justify-center"
src={presignedUrl || ''}
name={name}
mimetype={mimetype}
size={size}
>
<img
src={url}
alt="card cover"
className={isSystemFile ? 'size-20' : 'size-full'}
style={{
objectFit: isCoverFit ? 'contain' : 'cover',
}}
/>
</FilePreviewItem>
</CarouselItem>
);
})}
</CarouselContent>
{value.length > 1 && (
<Fragment>
<CarouselPrevious className="left-1 size-7" onClick={(e) => e.stopPropagation()} />
<CarouselNext className="right-1 size-7" onClick={(e) => e.stopPropagation()} />
</Fragment>
)}
</Carousel>
</FilePreviewProvider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './Card';
export * from './CardCarousel';
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const GalleryProvider = ({ children }: { children: ReactNode }) => {
{tableId && (
<ExpandRecorder
tableId={tableId}
viewId={view?.id}
recordId={expandRecordId}
recordIds={expandRecordId ? [expandRecordId] : []}
onClose={() => setExpandRecordId(undefined)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,14 @@ export const GridViewBaseInner: React.FC<IGridViewBaseInnerProps> = (
) ?? {};
generateRecord(fieldValueMap, Math.max(targetIndex, 0), { anchorId, position }, num);
},
duplicateRecord: async () => {
if (!record || !activeViewId) return;
await Record.duplicateRecord(tableId, record.id, {
viewId: activeViewId,
anchorId: record.id,
position: 'after',
});
},
deleteRecords: async (selection) => {
deleteRecords(selection);
gridRef.current?.setSelection(emptySelection);
Expand Down Expand Up @@ -887,6 +895,7 @@ export const GridViewBaseInner: React.FC<IGridViewBaseInnerProps> = (
{expandRecord != null && (
<ExpandRecorder
tableId={expandRecord.tableId}
viewId={activeViewId}
recordId={expandRecord.recordId}
recordIds={[expandRecord.recordId]}
onClose={() => setExpandRecord(undefined)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Trash, ArrowUp, ArrowDown } from '@teable/icons';
import { Trash, ArrowUp, ArrowDown, Copy } from '@teable/icons';
import { useGridViewStore } from '@teable/sdk/components';
import { useTableId, useTablePermission, useView } from '@teable/sdk/hooks';
import {
Expand Down Expand Up @@ -43,6 +43,7 @@ enum MenuItemType {
Delete = 'Delete',
InsertAbove = 'InsertAbove',
InsertBelow = 'InsertBelow',
Duplicate = 'Duplicate',
}

const iconClassName = 'mr-2 h-4 w-4 shrink-0';
Expand Down Expand Up @@ -179,6 +180,19 @@ export const RecordMenu = () => {
},
},
],
[
{
type: MenuItemType.Duplicate,
name: t('sdk:expandRecord.duplicateRecord'),
icon: <Copy className={iconClassName} />,
hidden: isMultipleSelected || !permission['record|create'],
onClick: async () => {
if (tableId && recordMenu?.duplicateRecord) {
await recordMenu.duplicateRecord();
}
},
},
],
[
{
type: MenuItemType.Delete,
Expand Down
Loading

0 comments on commit e93dc65

Please sign in to comment.