mirror of
https://github.com/teableio/teable
synced 2024-11-22 23:38:39 +00:00
refactor: copyAndPaste module into selection module (#114)
* refactor: copyAndPaste module into selection module * fix: lint error
This commit is contained in:
parent
a4a199567b
commit
c4bbdf3ca5
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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[]> {
|
||||
|
@ -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),
|
||||
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[];
|
||||
}
|
||||
}
|
||||
|
@ -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', () => {
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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]]);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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!' });
|
@ -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>;
|
||||
|
@ -1,4 +0,0 @@
|
||||
export * as CopyAndPasteSchema from './schema';
|
||||
export * as CopyAndPastePath from './path';
|
||||
export * as CopyAndPasteRoute from './route';
|
||||
export * as CopyAndPasteApi from './api';
|
@ -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';
|
@ -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) {
|
||||
|
@ -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';
|
||||
|
4
packages/openapi/src/selection/index.ts
Normal file
4
packages/openapi/src/selection/index.ts
Normal 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';
|
3
packages/openapi/src/selection/path.ts
Normal file
3
packages/openapi/src/selection/path.ts
Normal 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';
|
29
packages/openapi/src/selection/route/clear.ts
Normal file
29
packages/openapi/src/selection/route/clear.ts
Normal 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'],
|
||||
};
|
@ -24,5 +24,5 @@ export const CopyRoute: RouteConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['copyAndPaste'],
|
||||
tags: ['selection'],
|
||||
};
|
@ -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'],
|
||||
};
|
@ -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,25 +52,21 @@ 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({
|
||||
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<typeof clearRoSchema>;
|
Loading…
Reference in New Issue
Block a user