From c4bbdf3ca555d0a8d6f4b1283480171234a3fb3e Mon Sep 17 00:00:00 2001 From: Boris Bo Wang Date: Tue, 1 Aug 2023 15:21:42 +0800 Subject: [PATCH] refactor: copyAndPaste module into selection module (#114) * refactor: copyAndPaste module into selection module * fix: lint error --- apps/nestjs-backend/src/app.module.ts | 4 +- .../copy-paste/copy-paste.controller.ts | 45 -- .../src/features/field/field.service.ts | 8 +- .../model/field-dto/attachment-field.dto.ts | 18 +- .../selection.controller.spec.ts} | 10 +- .../selection/selection.controller.ts | 43 ++ .../selection.module.ts} | 12 +- .../selection.service.spec.ts} | 339 +++++-------- .../selection.service.ts} | 473 +++++++++--------- .../app/blocks/view/grid/GridView.tsx | 4 +- ...pyAndPaste.ts => useSelectionOperation.ts} | 31 +- .../core/src/models/field/field.schema.ts | 1 + packages/openapi/src/copyAndPaste/index.ts | 4 - packages/openapi/src/copyAndPaste/path.ts | 3 - packages/openapi/src/generate.schema.ts | 4 +- packages/openapi/src/index.ts | 2 +- .../src/{copyAndPaste => selection}/api.ts | 0 packages/openapi/src/selection/index.ts | 4 + packages/openapi/src/selection/path.ts | 3 + packages/openapi/src/selection/route/clear.ts | 29 ++ .../{copyAndPaste => selection}/route/copy.ts | 2 +- .../route/index.ts | 0 .../route/paste.ts | 9 +- .../src/{copyAndPaste => selection}/schema.ts | 53 +- 24 files changed, 514 insertions(+), 587 deletions(-) delete mode 100644 apps/nestjs-backend/src/features/copy-paste/copy-paste.controller.ts rename apps/nestjs-backend/src/features/{copy-paste/copy-paste.controller.spec.ts => selection/selection.controller.spec.ts} (55%) create mode 100644 apps/nestjs-backend/src/features/selection/selection.controller.ts rename apps/nestjs-backend/src/features/{copy-paste/copy-paste.module.ts => selection/selection.module.ts} (66%) rename apps/nestjs-backend/src/features/{copy-paste/copy-paste.service.spec.ts => selection/selection.service.spec.ts} (56%) rename apps/nestjs-backend/src/features/{copy-paste/copy-paste.service.ts => selection/selection.service.ts} (50%) rename apps/nextjs-app/src/features/app/blocks/view/grid/hooks/{useCopyAndPaste.ts => useSelectionOperation.ts} (72%) delete mode 100644 packages/openapi/src/copyAndPaste/index.ts delete mode 100644 packages/openapi/src/copyAndPaste/path.ts rename packages/openapi/src/{copyAndPaste => selection}/api.ts (100%) create mode 100644 packages/openapi/src/selection/index.ts create mode 100644 packages/openapi/src/selection/path.ts create mode 100644 packages/openapi/src/selection/route/clear.ts rename packages/openapi/src/{copyAndPaste => selection}/route/copy.ts (95%) rename packages/openapi/src/{copyAndPaste => selection}/route/index.ts (100%) rename packages/openapi/src/{copyAndPaste => selection}/route/paste.ts (75%) rename packages/openapi/src/{copyAndPaste => selection}/schema.ts (57%) diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index 2f44f56cb..0f2a7541a 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -9,9 +9,9 @@ import { X_REQUEST_ID } from './const'; import { AttachmentsModule } from './features/attachments/attachments.module'; import { AutomationModule } from './features/automation/automation.module'; import { ChatModule } from './features/chat/chat.module'; -import { CopyPasteModule } from './features/copy-paste/copy-paste.module'; import { FileTreeModule } from './features/file-tree/file-tree.module'; import { NextModule } from './features/next/next.module'; +import { SelectionModule } from './features/selection/selection.module'; import { TableOpenApiModule } from './features/table/open-api/table-open-api.module'; import { TeableLoggerModule } from './logger/logger.module'; import { WsModule } from './ws/ws.module'; @@ -38,7 +38,7 @@ import { WsModule } from './ws/ws.module'; AttachmentsModule, AutomationModule, WsModule, - CopyPasteModule, + SelectionModule, EventEmitterModule.forRoot({ // set this to `true` to use wildcards wildcard: false, diff --git a/apps/nestjs-backend/src/features/copy-paste/copy-paste.controller.ts b/apps/nestjs-backend/src/features/copy-paste/copy-paste.controller.ts deleted file mode 100644 index 371ffb458..000000000 --- a/apps/nestjs-backend/src/features/copy-paste/copy-paste.controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { CopyAndPasteSchema } from '@teable-group/openapi'; -import { responseWrap } from '../../utils'; -import { ZodValidationPipe } from '../../zod.validation.pipe'; -import { CopyPasteService } from './copy-paste.service'; - -@ApiBearerAuth() -@ApiTags('copyPaste') -@Controller('api/table/:tableId/view/:viewId/copy-paste') -export class CopyPasteController { - constructor(private copyPasteService: CopyPasteService) {} - - @Get('/copy') - async copy( - @Param('tableId') tableId: string, - @Param('viewId') viewId: string, - @Query(new ZodValidationPipe(CopyAndPasteSchema.copyRoSchema)) query: CopyAndPasteSchema.CopyRo - ) { - const copyContent = await this.copyPasteService.copy(tableId, viewId, query); - return responseWrap(copyContent); - } - - @Post('/paste') - async paste( - @Param('tableId') tableId: string, - @Param('viewId') viewId: string, - @Body(new ZodValidationPipe(CopyAndPasteSchema.pasteRoSchema)) - pasteRo: CopyAndPasteSchema.PasteRo - ) { - await this.copyPasteService.paste(tableId, viewId, pasteRo); - return responseWrap(null); - } - - @Post('/clear') - async clear( - @Param('tableId') tableId: string, - @Param('viewId') viewId: string, - @Body(new ZodValidationPipe(CopyAndPasteSchema.clearRoSchema)) - clearRo: CopyAndPasteSchema.ClearRo - ) { - await this.copyPasteService.clear(tableId, viewId, clearRo); - return responseWrap(null); - } -} diff --git a/apps/nestjs-backend/src/features/field/field.service.ts b/apps/nestjs-backend/src/features/field/field.service.ts index 420d4b820..8dd8c9b8b 100644 --- a/apps/nestjs-backend/src/features/field/field.service.ts +++ b/apps/nestjs-backend/src/features/field/field.service.ts @@ -297,9 +297,15 @@ export class FieldService implements IAdapterService { const fields = fieldsPlain.map(rawField2FieldObj); - return sortBy(fields, (field) => { + const result = sortBy(fields, (field) => { return field.columnMeta[viewId as string].order; }); + + if (query.filterHidden) { + return result.filter((field) => !field.columnMeta[viewId as string].hidden); + } + + return result; } async getFieldInstances(tableId: string, query: IGetFieldsQuery): Promise { diff --git a/apps/nestjs-backend/src/features/field/model/field-dto/attachment-field.dto.ts b/apps/nestjs-backend/src/features/field/model/field-dto/attachment-field.dto.ts index 488034e77..540726e33 100644 --- a/apps/nestjs-backend/src/features/field/model/field-dto/attachment-field.dto.ts +++ b/apps/nestjs-backend/src/features/field/model/field-dto/attachment-field.dto.ts @@ -42,16 +42,22 @@ export class AttachmentFieldDto extends AttachmentFieldCore implements IFieldBas attachments?: Omit[] ) { // value is ddd.svg (https://xxx.xxx/xxx) - if (!attachments?.length) { + if (!attachments?.length || !value) { return null; } const tokens = value.split(',').map(AttachmentFieldDto.getTokenByString); return tokens - .map((token) => ({ - ...attachments.find((attachment) => attachment.token === token), - name: '', - id: generateAttachmentId(), - })) + .map((token) => { + const attachment = attachments.find((attachment) => attachment.token === token); + if (!attachment) { + return; + } + return { + ...attachment, + name: '', + id: generateAttachmentId(), + }; + }) .filter(Boolean) as IAttachmentItem[]; } } diff --git a/apps/nestjs-backend/src/features/copy-paste/copy-paste.controller.spec.ts b/apps/nestjs-backend/src/features/selection/selection.controller.spec.ts similarity index 55% rename from apps/nestjs-backend/src/features/copy-paste/copy-paste.controller.spec.ts rename to apps/nestjs-backend/src/features/selection/selection.controller.spec.ts index 269fbdcd4..84201025e 100644 --- a/apps/nestjs-backend/src/features/copy-paste/copy-paste.controller.spec.ts +++ b/apps/nestjs-backend/src/features/selection/selection.controller.spec.ts @@ -1,16 +1,16 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; -import { CopyPasteController } from './copy-paste.controller'; +import { SelectionController } from './selection.controller'; -describe('CopyPasteController', () => { - let controller: CopyPasteController; +describe('SelectionController', () => { + let controller: SelectionController; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - controllers: [CopyPasteController], + controllers: [SelectionController], }).compile(); - controller = module.get(CopyPasteController); + controller = module.get(SelectionController); }); it('should be defined', () => { diff --git a/apps/nestjs-backend/src/features/selection/selection.controller.ts b/apps/nestjs-backend/src/features/selection/selection.controller.ts new file mode 100644 index 000000000..10623b6d5 --- /dev/null +++ b/apps/nestjs-backend/src/features/selection/selection.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { SelectionSchema } from '@teable-group/openapi'; +import type { ApiResponse } from '../../utils'; +import { responseWrap } from '../../utils'; +import { ZodValidationPipe } from '../../zod.validation.pipe'; +import { SelectionService } from './selection.service'; + +@Controller('api/table/:tableId/view/:viewId/selection') +export class SelectionController { + constructor(private selectionService: SelectionService) {} + + @Get('/copy') + async copy( + @Param('tableId') tableId: string, + @Param('viewId') viewId: string, + @Query(new ZodValidationPipe(SelectionSchema.copyRoSchema)) query: SelectionSchema.CopyRo + ): Promise> { + const res = await this.selectionService.copy(tableId, viewId, query); + return responseWrap(res); + } + + @Post('/paste') + async paste( + @Param('tableId') tableId: string, + @Param('viewId') viewId: string, + @Body(new ZodValidationPipe(SelectionSchema.pasteRoSchema)) + pasteRo: SelectionSchema.PasteRo + ): Promise> { + const ranges = await this.selectionService.paste(tableId, viewId, pasteRo); + return responseWrap({ ranges }); + } + + @Post('/clear') + async clear( + @Param('tableId') tableId: string, + @Param('viewId') viewId: string, + @Body(new ZodValidationPipe(SelectionSchema.clearRoSchema)) + clearRo: SelectionSchema.ClearRo + ) { + await this.selectionService.clear(tableId, viewId, clearRo); + return responseWrap(null); + } +} diff --git a/apps/nestjs-backend/src/features/copy-paste/copy-paste.module.ts b/apps/nestjs-backend/src/features/selection/selection.module.ts similarity index 66% rename from apps/nestjs-backend/src/features/copy-paste/copy-paste.module.ts rename to apps/nestjs-backend/src/features/selection/selection.module.ts index 8b86d8e6c..c2c7a4b4f 100644 --- a/apps/nestjs-backend/src/features/copy-paste/copy-paste.module.ts +++ b/apps/nestjs-backend/src/features/selection/selection.module.ts @@ -5,13 +5,13 @@ import { FieldModule } from '../field/field.module'; import { FieldOpenApiModule } from '../field/open-api/field-open-api.module'; import { RecordOpenApiModule } from '../record/open-api/record-open-api.module'; import { RecordModule } from '../record/record.module'; -import { CopyPasteController } from './copy-paste.controller'; -import { CopyPasteService } from './copy-paste.service'; +import { SelectionController } from './selection.controller'; +import { SelectionService } from './selection.service'; @Module({ - providers: [CopyPasteService, PrismaService], - controllers: [CopyPasteController], + providers: [SelectionService, PrismaService], + controllers: [SelectionController], imports: [RecordModule, FieldModule, ShareDbModule, RecordOpenApiModule, FieldOpenApiModule], - exports: [CopyPasteService], + exports: [SelectionService], }) -export class CopyPasteModule {} +export class SelectionModule {} diff --git a/apps/nestjs-backend/src/features/copy-paste/copy-paste.service.spec.ts b/apps/nestjs-backend/src/features/selection/selection.service.spec.ts similarity index 56% rename from apps/nestjs-backend/src/features/copy-paste/copy-paste.service.spec.ts rename to apps/nestjs-backend/src/features/selection/selection.service.spec.ts index 061803e8f..16bafa382 100644 --- a/apps/nestjs-backend/src/features/copy-paste/copy-paste.service.spec.ts +++ b/apps/nestjs-backend/src/features/selection/selection.service.spec.ts @@ -3,9 +3,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; import type { IFieldVo, IRecord } from '@teable-group/core'; -import { FieldKeyType, FieldType } from '@teable-group/core'; -import { CopyAndPasteSchema } from '@teable-group/openapi'; -import { noop } from 'lodash'; +import { FieldKeyType, FieldType, nullsToUndefined } from '@teable-group/core'; import { PrismaService } from '../../prisma.service'; import { TransactionService } from '../../share-db/transaction.service'; import { FieldService } from '../field/field.service'; @@ -14,11 +12,11 @@ import { createFieldInstanceByRo } from '../field/model/factory'; import { FieldOpenApiService } from '../field/open-api/field-open-api.service'; import { RecordOpenApiService } from '../record/open-api/record-open-api.service'; import { RecordService } from '../record/record.service'; -import { CopyPasteModule } from './copy-paste.module'; -import { CopyPasteService } from './copy-paste.service'; +import { SelectionModule } from './selection.module'; +import { SelectionService } from './selection.service'; -describe('CopyPasteService', () => { - let copyPasteService: CopyPasteService; +describe('selectionService', () => { + let selectionService: SelectionService; let recordService: RecordService; let fieldService: FieldService; let prismaService: PrismaService; @@ -28,7 +26,7 @@ describe('CopyPasteService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [CopyPasteModule, EventEmitterModule.forRoot()], + imports: [SelectionModule, EventEmitterModule.forRoot()], }) .overrideProvider(PrismaService) .useValue({ @@ -38,7 +36,7 @@ describe('CopyPasteService', () => { }) .compile(); - copyPasteService = module.get(CopyPasteService); + selectionService = module.get(SelectionService); fieldService = module.get(FieldService); recordService = module.get(RecordService); prismaService = module.get(PrismaService); @@ -47,173 +45,46 @@ describe('CopyPasteService', () => { transactionService = module.get(TransactionService); }); - describe('getRangeTableContent', () => { - it('should return range table content', async () => { - const tableId = 'table1'; - const viewId = 'view1'; - const range = [ - [0, 0], - [1, 1], - ]; - const expectedFields = [ - { - id: 'field1', - type: FieldType.SingleLineText, - }, - { - id: 'field2', - type: FieldType.SingleLineText, - }, - ]; - const expectedRecords = { - records: [ - { - id: '', - recordOrder: { [viewId]: 1 }, - fields: { field1: 'value1', field2: 'value2' }, - }, - { - id: '', - recordOrder: { [viewId]: 2 }, - fields: { field1: 'value3', field2: 'value4' }, - }, - ], - total: 2, - }; - const expectedTableContent = [ - ['value1', 'value2'], - ['value3', 'value4'], - ]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - jest.spyOn(fieldService, 'getFields').mockResolvedValue(expectedFields as any); - jest.spyOn(recordService, 'getRecords').mockResolvedValue(expectedRecords); - - const result = await copyPasteService['getRangeTableContent'](tableId, viewId, range); - - expect(fieldService.getFields).toHaveBeenCalledWith(tableId, { viewId }); - expect(recordService.getRecords).toHaveBeenCalledWith(tableId, { - fieldKeyType: 'id', - viewId, - skip: 0, - take: 2, - }); - expect(result).toEqual(expectedTableContent); - }); - }); - - describe('mergeRangesData', () => { - it('should merge ranges data for column type', () => { - const rangesData = [ - [['a'], ['b']], - [['c'], ['d']], - ]; - const type = CopyAndPasteSchema.RangeType.Column; - const expectedMergedData = [ - ['a', 'c'], - ['b', 'd'], - ]; - - const result = copyPasteService['mergeRangesData'](rangesData, type); - - expect(result).toEqual(expectedMergedData); - }); - - it('should merge ranges data for row type', () => { - const rangesData = [ - [['a'], ['b']], - [['c'], ['d']], - ]; - const type = CopyAndPasteSchema.RangeType.Row; - const expectedMergedData = [['a'], ['b'], ['c'], ['d']]; - - const result = copyPasteService['mergeRangesData'](rangesData, type); - - expect(result).toEqual(expectedMergedData); - }); - }); - - describe('getCopyHeader', () => { - const tableId = 'table1'; - const viewId = 'view1'; - - it('should return the header fields for given ranges', async () => { - const mockFields = [ - { id: '3', name: 'Email', type: FieldType.SingleLineText }, - { id: '4', name: 'Phone', type: FieldType.SingleLineText }, - ] as IFieldVo[]; - const ranges: number[][] = [ - [0, 1], - [0, 2], - ]; - - jest.spyOn(fieldService, 'getFields').mockResolvedValue(mockFields); - const headerFields = await copyPasteService['getCopyHeader'](tableId, viewId, ranges); - expect(headerFields).toEqual([mockFields[0]]); - }); - }); + const tableId = 'table1'; + const viewId = 'view1'; describe('copy', () => { - const tableId = 'table1'; - const viewId = 'view1'; const range = '[[0, 0], [1, 1]]'; it('should return merged ranges data', async () => { - const expectedMergedData = [ - ['value1', 'value2'], - ['value3', 'value4'], + const mockSelectionCtxRecords = [ + { + id: 'record1', + recordOrder: {}, + fields: { + field1: '1', + field2: '2', + field3: '3', + }, + }, + { + id: 'record2', + recordOrder: {}, + fields: { + field1: '1', + field2: '2', + }, + }, ]; - - jest.spyOn(JSON, 'parse').mockReturnValue([ - [0, 0], - [1, 1], - ]); - jest.spyOn(copyPasteService as any, 'getRangeTableContent').mockResolvedValue([ - ['value1', 'value2'], - ['value3', 'value4'], - ]); - jest.spyOn(copyPasteService as any, 'mergeRangesData').mockReturnValue(expectedMergedData); - jest.spyOn(copyPasteService as any, 'getCopyHeader').mockReturnValue([]); - - const result = await copyPasteService.copy(tableId, viewId, { - ranges: range, - type: CopyAndPasteSchema.RangeType.Row, + const mockSelectionCtxFields = [ + { id: 'field1', name: 'Field 1', type: FieldType.SingleLineText }, + { id: 'field2', name: 'Field 2', type: FieldType.SingleLineText }, + ]; + jest.spyOn(selectionService as any, 'getSelectionCtxByRange').mockReturnValue({ + records: mockSelectionCtxRecords, + fields: mockSelectionCtxFields, }); - expect(JSON.parse).toHaveBeenCalledWith(range); - expect(copyPasteService['getRangeTableContent']).toHaveBeenCalledWith(tableId, viewId, [ - [0, 0], - [1, 1], - ]); - expect(copyPasteService['mergeRangesData']).toHaveBeenCalledWith( - [ - [ - ['value1', 'value2'], - ['value3', 'value4'], - ], - ], - CopyAndPasteSchema.RangeType.Row - ); - expect(result?.content).toEqual('value1\tvalue2\nvalue3\tvalue4'); - }); - - it('should return empty array when ranges array is empty', async () => { - jest.spyOn(JSON, 'parse').mockReturnValue([]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - jest.spyOn(copyPasteService as any, 'getRangeTableContent'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - jest.spyOn(copyPasteService as any, 'mergeRangesData'); - jest.spyOn(copyPasteService as any, 'getCopyHeader').mockReturnValue([]); - - const result = await copyPasteService.copy(tableId, viewId, { + const result = await selectionService.copy(tableId, viewId, { ranges: range, - type: CopyAndPasteSchema.RangeType.Row, }); - expect(JSON.parse).toHaveBeenCalledWith(range); - expect(copyPasteService['getRangeTableContent']).not.toHaveBeenCalled(); - expect(copyPasteService['mergeRangesData']).not.toHaveBeenCalled(); - expect(result?.content).toEqual(undefined); + expect(result?.content).toEqual('1\t2\n1\t2'); }); }); @@ -227,7 +98,7 @@ describe('CopyPasteService', () => { ]; // Perform the parsing - const result = copyPasteService['parseCopyContent'](content); + const result = selectionService['parseCopyContent'](content); // Verify the result expect(result).toEqual(expectedParsedContent); @@ -239,14 +110,11 @@ describe('CopyPasteService', () => { // Input const tableSize: [number, number] = [5, 4]; const cell: [number, number] = [2, 3]; - const content = [ - ['John', 'Doe'], - ['Jane', 'Smith'], - ]; + const tableDataSize: [number, number] = [2, 2]; const expectedExpansion = [0, 1]; // Perform the calculation - const result = copyPasteService['calculateExpansion'](tableSize, cell, content); + const result = selectionService['calculateExpansion'](tableSize, cell, tableDataSize); // Verify the result expect(result).toEqual(expectedExpansion); @@ -268,7 +136,7 @@ describe('CopyPasteService', () => { }); // Perform expanding rows - const result = await copyPasteService['expandRows']({ + const result = await selectionService['expandRows']({ tableId, numRowsToExpand, transactionKey, @@ -302,7 +170,7 @@ describe('CopyPasteService', () => { jest.spyOn(fieldOpenApiService, 'createField').mockResolvedValueOnce(header[1]); // Perform expanding columns - const result = await copyPasteService['expandColumns']({ + const result = await selectionService['expandColumns']({ tableId, viewId, header, @@ -335,8 +203,8 @@ describe('CopyPasteService', () => { url: '', size: 1, mimetype: 'image/png', - width: 10, - height: 10, + width: undefined, + height: undefined, }, { token: 'token2', @@ -358,14 +226,8 @@ describe('CopyPasteService', () => { }, ]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - // jest.spyOn(prismaService.attachments, 'findMany').mockImplementation(() => { - // return Promise.resolve([]) as any; - // }); - // (prismaService.attachments.findMany as jest.Mock).mockResolvedValue(mockAttachment); - (prismaService.attachments.findMany as jest.Mock).mockResolvedValue(mockAttachment); - const result = await copyPasteService['collectionAttachment']({ + const result = await selectionService['collectionAttachment']({ tableData, fields, }); @@ -387,14 +249,13 @@ describe('CopyPasteService', () => { }, }); // Assert the result based on the mocked attachments - expect(result).toEqual(mockAttachment); + expect(result).toEqual(nullsToUndefined(mockAttachment)); }); }); describe('fillCells', () => { it('should fill the cells with provided table data', async () => { // Mock data - const tableId = 'testTableId'; const tableData = [ ['A1', 'B1', 'C1'], ['A2', 'B2', 'C2'], @@ -412,58 +273,37 @@ describe('CopyPasteService', () => { { id: 'record2', recordOrder: {}, fields: {} }, { id: 'record3', recordOrder: {}, fields: {} }, ]; - const transactionKey = 'testTransactionKey'; - - // Mock service methods - const updateRecordByIdSpy = jest - .spyOn(recordOpenApiService, 'updateRecordById') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .mockResolvedValue(null as any); // Execute the method - await copyPasteService['fillCells']({ + const updateRecordsRo = await selectionService['fillCells']({ tableData, - tableId, fields, records, - transactionKey, }); - // Verify the service method calls - expect(updateRecordByIdSpy).toHaveBeenCalledTimes(tableData.length); - expect(updateRecordByIdSpy).toHaveBeenCalledWith( - tableId, - records[0].id, + expect(updateRecordsRo).toEqual([ { + recordId: records[0].id, record: { fields: { field1: 'A1', field2: 'B1', field3: 'C1' }, }, fieldKeyType: FieldKeyType.Id, }, - transactionKey - ); - expect(updateRecordByIdSpy).toHaveBeenCalledWith( - tableId, - records[1].id, { + recordId: records[1].id, record: { fields: { field1: 'A2', field2: 'B2', field3: 'C2' }, }, fieldKeyType: FieldKeyType.Id, }, - transactionKey - ); - expect(updateRecordByIdSpy).toHaveBeenCalledWith( - tableId, - records[2].id, { + recordId: records[2].id, record: { fields: { field1: 'A3', field2: 'B3', field3: 'C3' }, }, fieldKeyType: FieldKeyType.Id, }, - transactionKey - ); + ]); }); }); @@ -509,7 +349,7 @@ describe('CopyPasteService', () => { { id: 'newRecordId2', recordOrder: {}, fields: {} }, ]; - jest.spyOn(copyPasteService as any, 'parseCopyContent').mockReturnValue(tableData); + jest.spyOn(selectionService as any, 'parseCopyContent').mockReturnValue(tableData); jest.spyOn(recordService, 'getRowCount').mockResolvedValue(mockRecords.length); jest.spyOn(recordService, 'getRecords').mockResolvedValue({ @@ -519,20 +359,21 @@ describe('CopyPasteService', () => { jest.spyOn(fieldService, 'getFieldInstances').mockResolvedValue(mockFields); - jest.spyOn(copyPasteService as any, 'expandRows').mockResolvedValue({ + jest.spyOn(selectionService as any, 'expandRows').mockResolvedValue({ records: mockNewRecords, }); - jest.spyOn(copyPasteService as any, 'expandColumns').mockResolvedValue(mockNewFields); - jest.spyOn(copyPasteService as any, 'fillCells').mockImplementation(noop); + jest.spyOn(selectionService as any, 'expandColumns').mockResolvedValue(mockNewFields); jest.spyOn(transactionService, '$transaction').mockImplementation(async (_, callback) => { await callback(prismaService, testTransactionKey); }); + jest.spyOn(recordOpenApiService, 'updateRecords').mockResolvedValue(null as any); + // Call the method - const result = await copyPasteService.paste(tableId, viewId, pasteRo); + const result = await selectionService.paste(tableId, viewId, pasteRo); // Assertions - expect(copyPasteService['parseCopyContent']).toHaveBeenCalledWith(content); + expect(selectionService['parseCopyContent']).toHaveBeenCalledWith(content); expect(recordService.getRowCount).toHaveBeenCalledWith(prismaService, tableId, viewId); expect(recordService.getRecords).toHaveBeenCalledWith(tableId, { viewId, @@ -543,7 +384,7 @@ describe('CopyPasteService', () => { expect(fieldService.getFieldInstances).toHaveBeenCalledWith(tableId, { viewId }); - expect(copyPasteService['expandColumns']).toHaveBeenCalledWith({ + expect(selectionService['expandColumns']).toHaveBeenCalledWith({ tableId, viewId, header: mockFields, @@ -551,20 +392,68 @@ describe('CopyPasteService', () => { transactionKey: testTransactionKey, }); - expect(copyPasteService['expandRows']).toHaveBeenCalledWith({ + expect(selectionService['expandRows']).toHaveBeenCalledWith({ tableId, numRowsToExpand: 2, transactionKey: testTransactionKey, }); - expect(copyPasteService['fillCells']).toHaveBeenCalledWith({ + expect(result).toEqual([ + [2, 1], + [4, 3], + ]); + }); + }); + + describe('clear', () => { + const tableId = 'testTableId'; + const viewId = 'testViewId'; + const records = [ + { + id: 'record1', + recordOrder: {}, + fields: { + field1: '1', + field2: '2', + }, + }, + ]; + const fields = [ + { id: 'field1', name: 'Field 1', type: FieldType.SingleLineText }, + { id: 'field2', name: 'Field 2', type: FieldType.SingleLineText }, + ]; + + it('should clear both fields and records when type is undefined', async () => { + // Mock the required dependencies and their methods + const clearRo = { + ranges: [ + [0, 0], + [0, 0], + ] as [number, number][], + }; + const updateRecordsRo = {}; // Mock the updateRecordsRo object + + // Mock the required methods from the service + selectionService['getSelectionCtxByRange'] = jest.fn().mockResolvedValue({ fields, records }); + selectionService['fillCells'] = jest.fn().mockResolvedValue(updateRecordsRo); + recordOpenApiService.updateRecords = jest.fn().mockResolvedValue(null); + + // Call the clear method + await selectionService.clear(tableId, viewId, clearRo); + + // Expect the methods to have been called with the correct parameters + expect(selectionService['getSelectionCtxByRange']).toHaveBeenCalledWith( tableId, - tableData, - fields: mockFields.slice(pasteRo.cell[0]).concat(mockNewFields), - records: mockRecords.slice(pasteRo.cell[1]).concat(mockNewRecords), - transactionKey: testTransactionKey, + viewId, + clearRo.ranges, + undefined + ); + expect(selectionService['fillCells']).toHaveBeenCalledWith({ + tableData: [], + fields, + records, }); - expect(result).toBeUndefined(); + expect(recordOpenApiService.updateRecords).toHaveBeenCalledWith(tableId, updateRecordsRo); }); }); }); diff --git a/apps/nestjs-backend/src/features/copy-paste/copy-paste.service.ts b/apps/nestjs-backend/src/features/selection/selection.service.ts similarity index 50% rename from apps/nestjs-backend/src/features/copy-paste/copy-paste.service.ts rename to apps/nestjs-backend/src/features/selection/selection.service.ts index 162d154af..d6b6faac2 100644 --- a/apps/nestjs-backend/src/features/copy-paste/copy-paste.service.ts +++ b/apps/nestjs-backend/src/features/selection/selection.service.ts @@ -1,24 +1,21 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import type { IFieldRo, IFieldVo, IRecord } from '@teable-group/core'; +import { Injectable } from '@nestjs/common'; +import type { IFieldRo, IFieldVo, IRecord, IUpdateRecordRo } from '@teable-group/core'; import { FieldKeyType, FieldType, nullsToUndefined } from '@teable-group/core'; -import { CopyAndPasteSchema } from '@teable-group/openapi'; +import { SelectionSchema } from '@teable-group/openapi'; import { isNumber, isString, omit } from 'lodash'; +import { TransactionService } from '../..//share-db/transaction.service'; import { PrismaService } from '../../prisma.service'; import { ShareDbService } from '../../share-db/share-db.service'; -import { TransactionService } from '../../share-db/transaction.service'; import { FieldService } from '../field/field.service'; -import { - createFieldInstanceByRo, - createFieldInstanceByVo, - type IFieldInstance, -} from '../field/model/factory'; +import type { IFieldInstance } from '../field/model/factory'; +import { createFieldInstanceByRo, createFieldInstanceByVo } from '../field/model/factory'; import { AttachmentFieldDto } from '../field/model/field-dto/attachment-field.dto'; import { FieldOpenApiService } from '../field/open-api/field-open-api.service'; import { RecordOpenApiService } from '../record/open-api/record-open-api.service'; import { RecordService } from '../record/record.service'; @Injectable() -export class CopyPasteService { +export class SelectionService { constructor( private recordService: RecordService, private fieldService: FieldService, @@ -29,201 +26,72 @@ export class CopyPasteService { private shareDbService: ShareDbService ) {} - private async getRangeTableContent(tableId: string, viewId: string, range: number[][]) { - const [start, end] = range; - const fields = await this.fieldService.getFieldInstances(tableId, { viewId }); - const copyFields = fields.slice(start[0], end[0] + 1); - const records = await this.recordService.getRecords(tableId, { + private async columnsSelectionCtx(tableId: string, viewId: string, ranges: number[][]) { + const { records } = await this.recordService.getRecords(tableId, { + viewId, + skip: 0, + take: -1, + fieldKeyType: FieldKeyType.Id, + }); + const fields = await this.fieldService.getFields(tableId, { viewId, filterHidden: true }); + + return { + records, + fields: ranges.reduce((acc, range) => { + return acc.concat(fields.slice(range[0], range[1] + 1)); + }, [] as IFieldVo[]), + }; + } + + private async rowsSelectionCtx(tableId: string, viewId: string, ranges: number[][]) { + const fields = await this.fieldService.getFields(tableId, { viewId, filterHidden: true }); + let records: IRecord[] = []; + for (const [start, end] of ranges) { + const recordsVo = await this.recordService.getRecords(tableId, { + viewId, + skip: start, + take: end + 1 - start, + fieldKeyType: FieldKeyType.Id, + }); + records = records.concat(recordsVo.records); + } + + return { + records, + fields, + }; + } + + private async defaultSelectionCtx(tableId: string, viewId: string, ranges: number[][]) { + const [start, end] = ranges; + const fields = await this.fieldService.getFieldInstances(tableId, { + viewId, + filterHidden: true, + }); + const { records } = await this.recordService.getRecords(tableId, { viewId, skip: start[1], take: end[1] + 1 - start[1], fieldKeyType: FieldKeyType.Id, }); - return records.records.map(({ fields }) => - copyFields.map((field) => field.cellValue2String(fields[field.id] as never)) - ); + return { records, fields: fields.slice(start[0], end[0] + 1) }; } - private mergeRangesData(rangesData: string[][][], type?: CopyAndPasteSchema.RangeType) { - if (rangesData.length === 0) { - return []; - } - if (type === CopyAndPasteSchema.RangeType.Column) { - return rangesData.reduce((result, subArray) => { - subArray.forEach((row, index) => { - result[index] = result[index] ? result[index].concat(row) : row; - }); - return result; - }, []); - } - return rangesData.reduce((acc, row) => acc.concat(row), []); - } - - private async getCopyHeader( + private async getSelectionCtxByRange( tableId: string, viewId: string, - ranges: number[][] - ): Promise { - const fields = await this.fieldService.getFields(tableId, { viewId }); - let headerFields: IFieldVo[] = []; - for (let i = 0; i < ranges.length; i += 2) { - const [start, end] = ranges.slice(i, i + 2); - const copyFields = fields.slice(start[0], end[0] + 1); - headerFields = headerFields.concat(copyFields); - } - return headerFields; - } - - async copy(tableId: string, viewId: string, query: CopyAndPasteSchema.CopyRo) { - const { ranges, type } = query; - const rangesArray = JSON.parse(ranges) as number[][]; - const rangesDataArray = []; - for (let i = 0; i < rangesArray.length; i += 2) { - const data = await this.getRangeTableContent(tableId, viewId, rangesArray.slice(i, i + 2)); - rangesDataArray.push(data); - } - - if (rangesArray.length === 0) { - return; - } - - if (rangesDataArray.length > 1 && !type) { - throw new HttpException( - 'ranges length is more than 3, must be set type', - HttpStatus.INTERNAL_SERVER_ERROR - ); - } - - const copyHeader = await this.getCopyHeader(tableId, viewId, rangesArray); - - return { - content: this.mergeRangesData(rangesDataArray, type) - .map((row) => row.join('\t')) - .join('\n'), - header: copyHeader, - }; - } - - async paste(tableId: string, viewId: string, pasteRo: CopyAndPasteSchema.PasteRo) { - const { cell, content, header } = pasteRo; - const [col, row] = cell; - const tableData = this.parseCopyContent(content); - const tableColCount = tableData[0].length; - - const rowCountInView = await this.recordService.getRowCount( - this.prismaService, - tableId, - viewId - ); - - const { records } = await this.recordService.getRecords(tableId, { - viewId, - skip: row, - take: tableData.length, - fieldKeyType: FieldKeyType.Id, - }); - const fields = await this.fieldService.getFieldInstances(tableId, { viewId }); - const effectFields = fields.slice(col, col + tableColCount); - - const tableSize: [number, number] = [fields.length, rowCountInView]; - const [numColsToExpand, numRowsToExpand] = this.calculateExpansion(tableSize, cell, tableData); - - return await this.transactionService.$transaction( - this.shareDbService, - async (_prisma, transactionKey) => { - // Expansion col - const expandColumns = await this.expandColumns({ - tableId, - viewId, - header, - numColsToExpand, - transactionKey, - }); - - // Expansion row - const expandRows = await this.expandRows({ tableId, numRowsToExpand, transactionKey }); - - // Fill cells - await this.fillCells({ - tableId, - tableData, - fields: effectFields.concat(expandColumns.map(createFieldInstanceByVo)), - records: records.concat(expandRows.records), - transactionKey, - }); + ranges: number[][], + type?: SelectionSchema.RangeType + ) { + switch (type) { + case SelectionSchema.RangeType.Columns: { + return await this.columnsSelectionCtx(tableId, viewId, ranges); } - ); - } - - private parseCopyContent(content: string): string[][] { - const rows = content.split('\n'); - return rows.map((row) => row.split('\t')); - } - - private calculateExpansion( - tableSize: [number, number], - cell: [number, number], - content: string[][] - ): [number, number] { - const [numCols, numRows] = tableSize; - - const endCol = cell[0] + content[0].length; - const endRow = cell[1] + content.length; - - const numRowsToExpand = Math.max(0, endRow - numRows); - const numColsToExpand = Math.max(0, endCol - numCols); - - return [numColsToExpand, numRowsToExpand]; - } - - private async fillCells({ - tableData, - tableId, - fields, - records, - transactionKey, - isClear, - }: { - tableData: string[][]; - tableId: string; - fields: IFieldInstance[]; - records: IRecord[]; - transactionKey: string; - isClear?: boolean; - }) { - const attachments = await this.collectionAttachment({ - fields, - tableData, - }); - for (let i = 0; i < records.length; i++) { - const rowData = tableData?.[i]; - const recordFields: IRecord['fields'] = {}; - const row = i; - for (let j = 0; j < fields.length; j++) { - const value = rowData?.[j] ?? null; - const col = j; - const field = fields[col]; - if (field.isComputed) { - continue; - } - if (isClear) { - recordFields[field.id] = null; - continue; - } - recordFields[field.id] = field.convertStringToCellValue( - value, - nullsToUndefined(attachments) - ); + case SelectionSchema.RangeType.Rows: { + return await this.rowsSelectionCtx(tableId, viewId, ranges); } - await this.recordOpenApiService.updateRecordById( - tableId, - records[row].id, - { - record: { fields: recordFields }, - fieldKeyType: FieldKeyType.Id, - }, - transactionKey - ); + default: + return await this.defaultSelectionCtx(tableId, viewId, ranges); } } @@ -297,50 +165,181 @@ export class CopyPasteService { return acc.concat(tokensInRecord); }, [] as string[]); - return await this.prismaService.attachments.findMany({ - where: { - token: { - in: tokens, + return nullsToUndefined( + await this.prismaService.attachments.findMany({ + where: { + token: { + in: tokens, + }, }, - }, - select: { - token: true, - size: true, - mimetype: true, - width: true, - height: true, - path: true, - url: true, - }, - }); - } - - async clear(tableId: string, viewId: string, clearRo: CopyAndPasteSchema.ClearRo) { - const { ranges } = clearRo; - - return await this.transactionService.$transaction( - this.shareDbService, - async (_prisma, transactionKey) => { - for (let i = 0; i < ranges.length; i = +2) { - const [start, end] = [ranges[i], ranges[i + 1]]; - const records = await this.recordService.getRecords(tableId, { - viewId, - skip: start[1], - take: end[1] + 1 - start[1], - fieldKeyType: FieldKeyType.Id, - }); - const fields = await this.fieldService.getFieldInstances(tableId, { viewId }); - const effectFields = fields.slice(start[0], end[0] + 1); - await this.fillCells({ - tableData: [], - tableId, - fields: effectFields, - records: records.records, - transactionKey, - isClear: true, - }); - } - } + select: { + token: true, + size: true, + mimetype: true, + width: true, + height: true, + path: true, + url: true, + }, + }) ); } + + private parseCopyContent(content: string): string[][] { + const rows = content.split('\n'); + return rows.map((row) => row.split('\t')); + } + + private calculateExpansion( + tableSize: [number, number], + cell: [number, number], + tableDataSize: [number, number] + ): [number, number] { + const [numCols, numRows] = tableSize; + const [dataNumCols, dataNumRows] = tableDataSize; + + const endCol = cell[0] + dataNumCols; + const endRow = cell[1] + dataNumRows; + + const numRowsToExpand = Math.max(0, endRow - numRows); + const numColsToExpand = Math.max(0, endCol - numCols); + + return [numColsToExpand, numRowsToExpand]; + } + + private async fillCells({ + tableData, + fields, + records, + }: { + tableData: string[][]; + fields: IFieldInstance[]; + records: IRecord[]; + }) { + const attachments = await this.collectionAttachment({ + fields, + tableData, + }); + const updateRecordsRo: (IUpdateRecordRo & { recordId: string })[] = []; + fields.forEach((field, col) => { + if (field.isComputed) { + return; + } + records.forEach((record, row) => { + const stringValue = tableData?.[row]?.[col] ?? null; + const recordField = updateRecordsRo[row]?.record?.fields || {}; + + if (stringValue === null) { + recordField[field.id] = null; + } else { + recordField[field.id] = field.convertStringToCellValue(stringValue, attachments); + } + + updateRecordsRo[row] = { + recordId: record.id, + record: { fields: recordField }, + fieldKeyType: FieldKeyType.Id, + }; + }); + }); + return updateRecordsRo; + } + + async copy(tableId: string, viewId: string, query: SelectionSchema.CopyRo) { + const { ranges, type } = query; + const rangesArray = JSON.parse(ranges) as number[][]; + const { fields, records } = await this.getSelectionCtxByRange( + tableId, + viewId, + rangesArray, + type + ); + const fieldInstances = fields.map(createFieldInstanceByVo); + const rectangleData = records.map((record) => + fieldInstances.map((fieldInstance) => + fieldInstance.cellValue2String(record.fields[fieldInstance.id] as never) + ) + ); + + return { + content: rectangleData.map((row) => row.join('\t')).join('\n'), + header: fields, + }; + } + + async paste(tableId: string, viewId: string, pasteRo: SelectionSchema.PasteRo) { + const { cell, content, header } = pasteRo; + const [col, row] = cell; + const tableData = this.parseCopyContent(content); + const tableColCount = tableData[0].length; + const tableRowCount = tableData.length; + + const rowCountInView = await this.recordService.getRowCount( + this.prismaService, + tableId, + viewId + ); + + const { records } = await this.recordService.getRecords(tableId, { + viewId, + skip: row, + take: tableData.length, + fieldKeyType: FieldKeyType.Id, + }); + const fields = await this.fieldService.getFieldInstances(tableId, { viewId }); + const effectFields = fields.slice(col, col + tableColCount); + + const tableSize: [number, number] = [fields.length, rowCountInView]; + const [numColsToExpand, numRowsToExpand] = this.calculateExpansion(tableSize, cell, [ + tableColCount, + tableRowCount, + ]); + + const updateRange: SelectionSchema.PasteVo['ranges'] = [cell, cell]; + + await this.transactionService.$transaction( + this.shareDbService, + async (_prisma, transactionKey) => { + // Expansion col + const expandColumns = await this.expandColumns({ + tableId, + viewId, + header, + numColsToExpand, + transactionKey, + }); + + // Expansion row + const expandRows = await this.expandRows({ tableId, numRowsToExpand, transactionKey }); + + const updateFields = effectFields.concat(expandColumns.map(createFieldInstanceByVo)); + const updateRecords = records.concat(expandRows.records); + + // Fill cells + const updateRecordsRo = await this.fillCells({ + tableData, + fields: updateFields, + records: updateRecords, + }); + + updateRange[1] = [col + updateFields.length - 1, row + updateFields.length - 1]; + await this.recordOpenApiService.updateRecords(tableId, updateRecordsRo, transactionKey); + } + ); + + return updateRange; + } + + async clear(tableId: string, viewId: string, clearRo: SelectionSchema.ClearRo) { + const { ranges, type } = clearRo; + const { fields, records } = await this.getSelectionCtxByRange(tableId, viewId, ranges, type); + const fieldInstances = fields.map(createFieldInstanceByVo); + const updateRecordsRo = await this.fillCells({ + tableData: [], + fields: fieldInstances, + records, + }); + + await this.recordOpenApiService.updateRecords(tableId, updateRecordsRo); + } } diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/GridView.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/GridView.tsx index 8f095ccce..551b5871b 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/GridView.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/GridView.tsx @@ -21,7 +21,7 @@ import type { CombinedSelection } from '../../grid/managers'; import { GIRD_ROW_HEIGHT_DEFINITIONS } from './const'; import { DomBox } from './DomBox'; import { useAsyncData, useColumnOrder, useColumnResize, useColumns, useGridTheme } from './hooks'; -import { useCopyAndPaste } from './hooks/useCopyAndPaste'; +import { useSelectionOperation } from './hooks/useSelectionOperation'; import { useGridViewStore } from './store/gridView'; import { getHeaderIcons } from './utils'; @@ -40,7 +40,7 @@ export const GridView: React.FC = () => { const gridViewStore = useGridViewStore(); const preTableId = usePrevious(tableId); const [isReadyToRender, setReadyToRender] = useState(false); - const { copy, paste, clear } = useCopyAndPaste(); + const { copy, paste, clear } = useSelectionOperation(); const { getCellContent, onVisibleRegionChanged, onCellEdited, onRowOrdered, reset, records } = useAsyncData( diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useCopyAndPaste.ts b/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts similarity index 72% rename from apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useCopyAndPaste.ts rename to apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts index 88f6e3221..698d738b9 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useCopyAndPaste.ts +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/hooks/useSelectionOperation.ts @@ -1,11 +1,18 @@ -import { CopyAndPasteApi, CopyAndPasteSchema } from '@teable-group/openapi'; +import { SelectionSchema, SelectionApi } from '@teable-group/openapi'; import { useTableId, useViewId } from '@teable-group/sdk'; import { useToast } from '@teable-group/ui-lib'; import { useCallback } from 'react'; import { SelectionRegionType } from '../../../grid'; import type { CombinedSelection } from '../../../grid/managers'; -export const useCopyAndPaste = () => { +const rangeTypes = { + [SelectionRegionType.Columns]: SelectionSchema.RangeType.Columns, + [SelectionRegionType.Rows]: SelectionSchema.RangeType.Rows, + [SelectionRegionType.Cells]: undefined, + [SelectionRegionType.None]: undefined, +}; + +export const useSelectionOperation = () => { const tableId = useTableId(); const viewId = useViewId(); const { toast } = useToast(); @@ -22,16 +29,9 @@ export const useCopyAndPaste = () => { }); const ranges = JSON.stringify(selection.serialize()); - const rangeTypes = { - [SelectionRegionType.Columns]: CopyAndPasteSchema.RangeType.Column, - [SelectionRegionType.Rows]: CopyAndPasteSchema.RangeType.Row, - [SelectionRegionType.Cells]: undefined, - [SelectionRegionType.None]: undefined, - }; - const type = rangeTypes[selection.type]; - const { data } = await CopyAndPasteApi.copy(tableId, viewId, { + const { data } = await SelectionApi.copy(tableId, viewId, { ranges, ...(type ? { type } : {}), }); @@ -40,7 +40,7 @@ export const useCopyAndPaste = () => { } const { content, header } = data.data; await navigator.clipboard.writeText(content); - localStorage.setItem(copyHeaderKey, JSON.stringify(header)); + sessionStorage.setItem(copyHeaderKey, JSON.stringify(header)); toaster.update({ id: toaster.id, title: 'Copied success!' }); }, [tableId, toast, viewId] @@ -54,11 +54,11 @@ export const useCopyAndPaste = () => { const toaster = toast({ title: 'Pasting...', }); - const headerStr = localStorage.getItem(copyHeaderKey); + const headerStr = sessionStorage.getItem(copyHeaderKey); const header = headerStr ? JSON.parse(headerStr) : undefined; const ranges = selection.ranges; const content = await navigator.clipboard.readText(); - await CopyAndPasteApi.paste(tableId, viewId, { + await SelectionApi.paste(tableId, viewId, { content, cell: ranges[0], header, @@ -77,8 +77,11 @@ export const useCopyAndPaste = () => { title: 'Clearing...', }); - await CopyAndPasteApi.clear(tableId, viewId, { + const type = rangeTypes[selection.type]; + + await SelectionApi.clear(tableId, viewId, { ranges: selection.ranges, + ...(type ? { type } : {}), }); toaster.update({ id: toaster.id, title: 'Clear success!' }); diff --git a/packages/core/src/models/field/field.schema.ts b/packages/core/src/models/field/field.schema.ts index d4f7530b5..42e17234f 100644 --- a/packages/core/src/models/field/field.schema.ts +++ b/packages/core/src/models/field/field.schema.ts @@ -200,6 +200,7 @@ export const getFieldsQuerySchema = z.object({ viewId: z.string().startsWith(IdPrefix.View).optional().openapi({ description: 'The id of the view.', }), + filterHidden: z.boolean().optional(), }); export type IGetFieldsQuery = z.infer; diff --git a/packages/openapi/src/copyAndPaste/index.ts b/packages/openapi/src/copyAndPaste/index.ts deleted file mode 100644 index 71e5885e8..000000000 --- a/packages/openapi/src/copyAndPaste/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * as CopyAndPasteSchema from './schema'; -export * as CopyAndPastePath from './path'; -export * as CopyAndPasteRoute from './route'; -export * as CopyAndPasteApi from './api'; diff --git a/packages/openapi/src/copyAndPaste/path.ts b/packages/openapi/src/copyAndPaste/path.ts deleted file mode 100644 index 298e64980..000000000 --- a/packages/openapi/src/copyAndPaste/path.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const COPY_URL = '/table/{tableId}/view/{viewId}/copy-paste/copy'; -export const PASTE_URL = '/table/{tableId}/view/{viewId}/copy-paste/paste'; -export const CLEAR_URL = '/table/{tableId}/view/{viewId}/copy-paste/clear'; diff --git a/packages/openapi/src/generate.schema.ts b/packages/openapi/src/generate.schema.ts index 5db916fe3..4ef523d02 100644 --- a/packages/openapi/src/generate.schema.ts +++ b/packages/openapi/src/generate.schema.ts @@ -1,15 +1,15 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; import { AttachmentRoute } from './attachment'; -import { CopyAndPasteRoute } from './copyAndPaste'; import { RecordRoute } from './record'; +import { SelectionRoute } from './selection'; function registerAllRoute() { const registry = new OpenAPIRegistry(); const routeObjList: Record[] = [ AttachmentRoute, RecordRoute, - CopyAndPasteRoute, + SelectionRoute, ]; for (const routeObj of routeObjList) { for (const routeKey in routeObj) { diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index 35dd20793..4aa794700 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -6,4 +6,4 @@ extendZodWithOpenApi(z); export * from './generate.schema'; export { AttachmentPath, AttachmentSchema, AttachmentApi } from './attachment'; export { RecordPath, RecordSchema, RecordApi } from './record'; -export { CopyAndPasteSchema, CopyAndPasteApi, CopyAndPastePath } from './copyAndPaste'; +export { SelectionSchema, SelectionApi, SelectionPath } from './selection'; diff --git a/packages/openapi/src/copyAndPaste/api.ts b/packages/openapi/src/selection/api.ts similarity index 100% rename from packages/openapi/src/copyAndPaste/api.ts rename to packages/openapi/src/selection/api.ts diff --git a/packages/openapi/src/selection/index.ts b/packages/openapi/src/selection/index.ts new file mode 100644 index 000000000..b0265ac3d --- /dev/null +++ b/packages/openapi/src/selection/index.ts @@ -0,0 +1,4 @@ +export * as SelectionSchema from './schema'; +export * as SelectionPath from './path'; +export * as SelectionRoute from './route'; +export * as SelectionApi from './api'; diff --git a/packages/openapi/src/selection/path.ts b/packages/openapi/src/selection/path.ts new file mode 100644 index 000000000..ad1bf356b --- /dev/null +++ b/packages/openapi/src/selection/path.ts @@ -0,0 +1,3 @@ +export const COPY_URL = '/table/{tableId}/view/{viewId}/selection/copy'; +export const PASTE_URL = '/table/{tableId}/view/{viewId}/selection/paste'; +export const CLEAR_URL = '/table/{tableId}/view/{viewId}/selection/clear'; diff --git a/packages/openapi/src/selection/route/clear.ts b/packages/openapi/src/selection/route/clear.ts new file mode 100644 index 000000000..ef8c546e1 --- /dev/null +++ b/packages/openapi/src/selection/route/clear.ts @@ -0,0 +1,29 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; +import { CLEAR_URL } from '../path'; +import { clearRoSchema } from '../schema'; + +export const ClearRoute: RouteConfig = { + method: 'post', + path: CLEAR_URL, + description: 'Clarify the constituency section', + request: { + params: z.object({ + teableId: z.string(), + viewId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: clearRoSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Successful clean up', + }, + }, + tags: ['selection'], +}; diff --git a/packages/openapi/src/copyAndPaste/route/copy.ts b/packages/openapi/src/selection/route/copy.ts similarity index 95% rename from packages/openapi/src/copyAndPaste/route/copy.ts rename to packages/openapi/src/selection/route/copy.ts index e6db87361..529f153f2 100644 --- a/packages/openapi/src/copyAndPaste/route/copy.ts +++ b/packages/openapi/src/selection/route/copy.ts @@ -24,5 +24,5 @@ export const CopyRoute: RouteConfig = { }, }, }, - tags: ['copyAndPaste'], + tags: ['selection'], }; diff --git a/packages/openapi/src/copyAndPaste/route/index.ts b/packages/openapi/src/selection/route/index.ts similarity index 100% rename from packages/openapi/src/copyAndPaste/route/index.ts rename to packages/openapi/src/selection/route/index.ts diff --git a/packages/openapi/src/copyAndPaste/route/paste.ts b/packages/openapi/src/selection/route/paste.ts similarity index 75% rename from packages/openapi/src/copyAndPaste/route/paste.ts rename to packages/openapi/src/selection/route/paste.ts index a362cdfc7..5c2cdee50 100644 --- a/packages/openapi/src/copyAndPaste/route/paste.ts +++ b/packages/openapi/src/selection/route/paste.ts @@ -1,7 +1,7 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; import { PASTE_URL } from '../path'; -import { pasteRoSchema } from '../schema'; +import { pasteRoSchema, pasteVoSchema } from '../schema'; export const PasteRoute: RouteConfig = { method: 'post', @@ -23,7 +23,12 @@ export const PasteRoute: RouteConfig = { responses: { 200: { description: 'Paste successfully', + content: { + 'application/json': { + schema: pasteVoSchema, + }, + }, }, }, - tags: ['copyAndPaste'], + tags: ['selection'], }; diff --git a/packages/openapi/src/copyAndPaste/schema.ts b/packages/openapi/src/selection/schema.ts similarity index 57% rename from packages/openapi/src/copyAndPaste/schema.ts rename to packages/openapi/src/selection/schema.ts index a475019e1..8da598ea6 100644 --- a/packages/openapi/src/copyAndPaste/schema.ts +++ b/packages/openapi/src/selection/schema.ts @@ -1,12 +1,14 @@ import { fieldVoSchema } from '@teable-group/core'; import { z } from 'zod'; +const cellSchema = z.tuple([z.number(), z.number()]); + export const pasteRoSchema = z.object({ content: z.string().openapi({ description: 'Content to paste', example: 'John\tDoe\tjohn.doe@example.com', }), - cell: z.tuple([z.number(), z.number()]).openapi({ + cell: cellSchema.openapi({ description: 'Starting cell for paste operation', example: [1, 2], }), @@ -18,28 +20,21 @@ export const pasteRoSchema = z.object({ export type PasteRo = z.infer; -const rangeValidator = (value: string) => { - const arrayValue = JSON.parse(value); - if (!Array.isArray(arrayValue) || arrayValue.length % 2 !== 0) { - return false; - } - for (const arr of arrayValue) { - if (!Array.isArray(arr) || arr.length !== 2) { - return false; - } - } - return true; -}; +export const pasteVoSchema = z.object({ + ranges: z.tuple([cellSchema, cellSchema]), +}); + +export type PasteVo = z.infer; export enum RangeType { - Column = 'column', - Row = 'row', + Rows = 'Rows', + Columns = 'Columns', } export const copyRoSchema = z.object({ ranges: z .string() - .refine((value) => rangeValidator(value), { + .refine((value) => z.array(cellSchema).safeParse(JSON.parse(value)).success, { message: 'The range parameter must be a valid 2D array with even length.', }) .openapi({ @@ -49,7 +44,7 @@ export const copyRoSchema = z.object({ }), type: z.nativeEnum(RangeType).optional().openapi({ description: 'Types of non-contiguous selections', - example: RangeType.Column, + example: RangeType.Columns, }), }); @@ -57,25 +52,21 @@ export type CopyRo = z.infer; export const copyVoSchema = z.object({ content: z.string(), - header: z.array(fieldVoSchema), + header: fieldVoSchema.array(), }); export type CopyVo = z.infer; export const clearRoSchema = z.object({ - ranges: z - .array(z.tuple([z.number(), z.number()])) - .refine((value) => value.length % 2 === 0, { - message: 'The range parameter must be a valid 2D array with even length.', - }) - .openapi({ - description: - 'The parameter "ranges" is used to represent the coordinates of a selected range in a table. ', - example: [ - [0, 0], - [1, 1], - ], - }), + ranges: z.array(cellSchema).openapi({ + description: + 'The parameter "ranges" is used to represent the coordinates of a selected range in a table. ', + example: [ + [0, 0], + [1, 1], + ], + }), + type: copyRoSchema.shape.type, }); export type ClearRo = z.infer;