refactor: copyAndPaste module into selection module (#114)

* refactor: copyAndPaste module into selection module

* fix: lint error
This commit is contained in:
Boris Bo Wang 2023-08-01 15:21:42 +08:00 committed by GitHub
parent a4a199567b
commit c4bbdf3ca5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 514 additions and 587 deletions

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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<IFieldInstance[]> {

View File

@ -42,16 +42,22 @@ export class AttachmentFieldDto extends AttachmentFieldCore implements IFieldBas
attachments?: Omit<IAttachmentItem, 'id' | 'name'>[]
) {
// 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),
.map((token) => {
const attachment = attachments.find((attachment) => attachment.token === token);
if (!attachment) {
return;
}
return {
...attachment,
name: '',
id: generateAttachmentId(),
}))
};
})
.filter(Boolean) as IAttachmentItem[];
}
}

View File

@ -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>(CopyPasteController);
controller = module.get<SelectionController>(SelectionController);
});
it('should be defined', () => {

View File

@ -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<ApiResponse<SelectionSchema.CopyVo>> {
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<ApiResponse<SelectionSchema.PasteVo>> {
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);
}
}

View File

@ -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 {}

View File

@ -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>(CopyPasteService);
selectionService = module.get<SelectionService>(SelectionService);
fieldService = module.get<FieldService>(FieldService);
recordService = module.get<RecordService>(RecordService);
prismaService = module.get<PrismaService>(PrismaService);
@ -47,173 +45,46 @@ describe('CopyPasteService', () => {
transactionService = module.get<TransactionService>(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]]);
});
});
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',
},
},
];
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,
});
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, {
const result = await selectionService.copy(tableId, viewId, {
ranges: range,
type: CopyAndPasteSchema.RangeType.Row,
});
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, {
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({
tableId,
tableData,
fields: mockFields.slice(pasteRo.cell[0]).concat(mockNewFields),
records: mockRecords.slice(pasteRo.cell[1]).concat(mockNewRecords),
transactionKey: testTransactionKey,
expect(result).toEqual([
[2, 1],
[4, 3],
]);
});
expect(result).toBeUndefined();
});
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,
viewId,
clearRo.ranges,
undefined
);
expect(selectionService['fillCells']).toHaveBeenCalledWith({
tableData: [],
fields,
records,
});
expect(recordOpenApiService.updateRecords).toHaveBeenCalledWith(tableId, updateRecordsRo);
});
});
});

View File

@ -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<IFieldVo[]> {
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);
ranges: number[][],
type?: SelectionSchema.RangeType
) {
switch (type) {
case SelectionSchema.RangeType.Columns: {
return await this.columnsSelectionCtx(tableId, viewId, ranges);
}
return headerFields;
case SelectionSchema.RangeType.Rows: {
return await this.rowsSelectionCtx(tableId, viewId, ranges);
}
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,
});
}
);
}
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)
);
}
await this.recordOpenApiService.updateRecordById(
tableId,
records[row].id,
{
record: { fields: recordFields },
fieldKeyType: FieldKeyType.Id,
},
transactionKey
);
default:
return await this.defaultSelectionCtx(tableId, viewId, ranges);
}
}
@ -297,7 +165,8 @@ export class CopyPasteService {
return acc.concat(tokensInRecord);
}, [] as string[]);
return await this.prismaService.attachments.findMany({
return nullsToUndefined(
await this.prismaService.attachments.findMany({
where: {
token: {
in: tokens,
@ -312,35 +181,165 @@ export class CopyPasteService {
path: true,
url: true,
},
});
})
);
}
async clear(tableId: string, viewId: string, clearRo: CopyAndPasteSchema.ClearRo) {
const { ranges } = clearRo;
private parseCopyContent(content: string): string[][] {
const rows = content.split('\n');
return rows.map((row) => row.split('\t'));
}
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, {
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,
skip: start[1],
take: end[1] + 1 - start[1],
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(start[0], end[0] + 1);
await this.fillCells({
tableData: [],
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,
fields: effectFields,
records: records.records,
viewId,
header,
numColsToExpand,
transactionKey,
isClear: true,
});
}
// 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);
}
}

View File

@ -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(

View File

@ -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!' });

View File

@ -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<typeof getFieldsQuerySchema>;

View File

@ -1,4 +0,0 @@
export * as CopyAndPasteSchema from './schema';
export * as CopyAndPastePath from './path';
export * as CopyAndPasteRoute from './route';
export * as CopyAndPasteApi from './api';

View File

@ -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';

View File

@ -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<string, RouteConfig>[] = [
AttachmentRoute,
RecordRoute,
CopyAndPasteRoute,
SelectionRoute,
];
for (const routeObj of routeObjList) {
for (const routeKey in routeObj) {

View File

@ -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';

View File

@ -0,0 +1,4 @@
export * as SelectionSchema from './schema';
export * as SelectionPath from './path';
export * as SelectionRoute from './route';
export * as SelectionApi from './api';

View File

@ -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';

View File

@ -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'],
};

View File

@ -24,5 +24,5 @@ export const CopyRoute: RouteConfig = {
},
},
},
tags: ['copyAndPaste'],
tags: ['selection'],
};

View File

@ -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'],
};

View File

@ -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<typeof pasteRoSchema>;
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<typeof pasteVoSchema>;
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,18 +52,13 @@ export type CopyRo = z.infer<typeof copyRoSchema>;
export const copyVoSchema = z.object({
content: z.string(),
header: z.array(fieldVoSchema),
header: fieldVoSchema.array(),
});
export type CopyVo = z.infer<typeof copyVoSchema>;
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({
ranges: z.array(cellSchema).openapi({
description:
'The parameter "ranges" is used to represent the coordinates of a selected range in a table. ',
example: [
@ -76,6 +66,7 @@ export const clearRoSchema = z.object({
[1, 1],
],
}),
type: copyRoSchema.shape.type,
});
export type ClearRo = z.infer<typeof clearRoSchema>;