Skip to content

Commit

Permalink
refactor: selecting deleted records in a grid view using a selection …
Browse files Browse the repository at this point in the history
…api (#656)

* refactor: selecting deleted records in a grid view using a selection api

* refactor: redefining the parameters needed for the record menu

* fix: cell selection
  • Loading branch information
boris-w committed Jun 11, 2024
1 parent 2cb765a commit a30bdf6
Show file tree
Hide file tree
Showing 9 changed files with 265 additions and 43 deletions.
13 changes: 11 additions & 2 deletions apps/nestjs-backend/src/features/selection/selection.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
import type { ICopyVo, IRangesToIdVo, IPasteVo } from '@teable/openapi';
import { Body, Controller, Delete, Get, Param, Patch, Query } from '@nestjs/common';
import type { ICopyVo, IRangesToIdVo, IPasteVo, IDeleteVo } from '@teable/openapi';
import {
IRangesToIdQuery,
rangesToIdQuerySchema,
Expand Down Expand Up @@ -57,4 +57,13 @@ export class SelectionController {
await this.selectionService.clear(tableId, rangesRo);
return null;
}

@Permissions('record|delete')
@Delete('/delete')
async delete(
@Param('tableId') tableId: string,
@Query(new ZodValidationPipe(rangesQuerySchema), TqlPipe) rangesRo: IRangesRo
): Promise<IDeleteVo> {
return this.selectionService.delete(tableId, rangesRo);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
IPasteRo,
IPasteVo,
IRangesRo,
IDeleteVo,
} from '@teable/openapi';
import { IdReturnType, RangeType } from '@teable/openapi';
import { isNumber, isString, map, pick } from 'lodash';
Expand Down Expand Up @@ -749,4 +750,11 @@ export class SelectionService {

await this.recordOpenApiService.updateRecords(tableId, updateRecordsRo);
}

async delete(tableId: string, rangesRo: IRangesRo): Promise<IDeleteVo> {
const { records } = await this.getSelectionCtxByRange(tableId, rangesRo);
const recordIds = records.map(({ id }) => id);
await this.recordOpenApiService.deleteRecords(tableId, recordIds);
return { ids: recordIds };
}
}
110 changes: 110 additions & 0 deletions apps/nestjs-backend/test/selection.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
FieldType,
MultiNumberDisplayType,
Relationship,
SortFunc,
defaultNumberFormatting,
} from '@teable/core';
import type { IFieldRo } from '@teable/core';
Expand All @@ -17,6 +18,8 @@ import {
copy as apiCopy,
paste as apiPaste,
getFields,
deleteSelection,
updateViewFilter,
} from '@teable/openapi';
import { createField, getRecord, initApp, createTable, deleteTable } from './utils/init-app';

Expand Down Expand Up @@ -383,4 +386,111 @@ describe('OpenAPI SelectionController (e2e)', () => {
expect(fields[4].options).toEqual(numberField.options);
});
});

describe('api/table/:tableId/selection/delete (DELETE)', () => {
let table: ITableFullVo;

beforeEach(async () => {
table = await createTable(baseId, {
name: 'table2',
fields: [
{
name: 'name',
type: FieldType.SingleLineText,
},
{
name: 'number',
type: FieldType.Number,
},
],
records: [
{ fields: { name: 'test', number: 1 } },
{ fields: { name: 'test2', number: 2 } },
{ fields: { name: 'test', number: 1 } },
],
});
});

afterEach(async () => {
await deleteTable(baseId, table.id);
});

it('should delete selected data', async () => {
const viewId = table.views[0].id;
const result = await deleteSelection(table.id, {
viewId,
type: RangeType.Rows,
ranges: [
[0, 0],
[2, 2],
],
});
console.log('result.data.ids', result.data.ids, table.records[0].id, table.records[2].id);
expect(result.data.ids).toEqual([table.records[0].id, table.records[2].id]);
});

it('should delete selected data with filter', async () => {
const viewId = table.views[0].id;
const result = await deleteSelection(table.id, {
viewId,
ranges: [
[0, 0],
[1, 1],
],
filter: {
conjunction: 'and',
filterSet: [
{
fieldId: table.fields[0].id,
value: 'test',
operator: 'is',
},
],
},
});
expect(result.data.ids).toEqual([table.records[0].id, table.records[2].id]);
});

it('should delete selected data with orderBy', async () => {
const viewId = table.views[0].id;
const result = await deleteSelection(table.id, {
viewId,
ranges: [
[0, 0],
[1, 1],
],
orderBy: [
{
fieldId: table.fields[0].id,
order: SortFunc.Desc,
},
],
});
expect(result.data.ids).toEqual([table.records[1].id, table.records[0].id]);
});

it('should delete selected data with view filter', async () => {
const viewId = table.views[0].id;
await updateViewFilter(table.id, viewId, {
filter: {
conjunction: 'and',
filterSet: [
{
fieldId: table.fields[0].id,
value: 'test',
operator: 'is',
},
],
},
});
const result = await deleteSelection(table.id, {
viewId,
ranges: [
[0, 0],
[1, 1],
],
});
expect(result.data.ids).toEqual([table.records[0].id, table.records[2].id]);
});
});
});
54 changes: 34 additions & 20 deletions apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ export const GridViewBase: React.FC<IGridViewProps> = (props: IGridViewProps) =>
viewGroupQuery
);

const { copy, paste, clear } = useSelectionOperation({ filter: viewGroupQuery?.filter });
const { copy, paste, clear, deleteRecords } = useSelectionOperation({
filter: viewGroupQuery?.filter,
});

const {
prefillingRowIndex,
Expand Down Expand Up @@ -275,26 +277,37 @@ export const GridViewBase: React.FC<IGridViewProps> = (props: IGridViewProps) =>
if (isCellSelection || isRowSelection) {
const rowStart = isCellSelection ? ranges[0][1] : ranges[0][0];
const rowEnd = isCellSelection ? ranges[1][1] : ranges[0][1];
const colStart = isCellSelection ? ranges[0][0] : 0;
const colEnd = isCellSelection ? ranges[1][0] : columns.length - 1;
const records = extract(rowStart, rowEnd, recordMap);
const selectColumns = extract(colStart, colEnd, columns);
const indexedColumns = keyBy(selectColumns, 'id');
const selectFields = fields.filter((field) => indexedColumns[field.id]);
const neighborRecords: Array<Record | null> = [];

if (records.length === 1) {
const isMultipleSelected =
(isRowSelection && ranges.length > 1) || Math.abs(rowEnd - rowStart) > 0;

if (isMultipleSelected) {
openRecordMenu({
position,
isMultipleSelected,
deleteRecords: async (selection) => {
deleteRecords(selection);
gridRef.current?.setSelection(emptySelection);
},
onAfterInsertCallback: callbackForPrefilling,
});
} else {
const record = recordMap[rowStart];
const neighborRecords: Array<Record | null> = [];
neighborRecords[0] = rowStart === 0 ? null : recordMap[rowStart - 1];
neighborRecords[1] = rowStart >= realRowCount - 1 ? null : recordMap[rowStart + 1];
}

openRecordMenu({
position,
records,
fields: selectFields,
neighborRecords,
onAfterInsertCallback: callbackForPrefilling,
});
openRecordMenu({
position,
record,
neighborRecords,
deleteRecords: async (selection) => {
deleteRecords(selection);
gridRef.current?.setSelection(emptySelection);
},
onAfterInsertCallback: callbackForPrefilling,
isMultipleSelected: false,
});
}
}

if (isColumnSelection) {
Expand All @@ -310,10 +323,11 @@ export const GridViewBase: React.FC<IGridViewProps> = (props: IGridViewProps) =>
columns,
recordMap,
fields,
realRowCount,
openRecordMenu,
openHeaderMenu,
deleteRecords,
callbackForPrefilling,
realRowCount,
openHeaderMenu,
]
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { FieldKeyType } from '@teable/core';
import { Trash, ArrowUp, ArrowDown } from '@teable/icons';
import { deleteRecords } from '@teable/openapi';
import { SelectionRegionType } from '@teable/sdk/components';
import { useTableId, useTablePermission, useView } from '@teable/sdk/hooks';
import { Record } from '@teable/sdk/model';
Expand Down Expand Up @@ -56,8 +55,8 @@ export const RecordMenu = () => {

if (recordMenu == null) return null;

const { records, onAfterInsertCallback } = recordMenu;
if (!records?.length) return null;
const { record, isMultipleSelected, onAfterInsertCallback } = recordMenu;
if (!record && !isMultipleSelected) return null;

const visible = Boolean(recordMenu);
const position = recordMenu?.position;
Expand Down Expand Up @@ -105,38 +104,38 @@ export const RecordMenu = () => {
type: MenuItemType.InsertAbove,
name: t('table:menu.insertRecordAbove'),
icon: <ArrowUp className={iconClassName} />,
hidden: records.length !== 1 || !permission['record|create'],
hidden: isMultipleSelected || !permission['record|create'],
disabled: isAutoSort,
onClick: async () => {
if (!tableId || !viewId) return;
await onInsertRecord(records[0].id, 'before');
if (!tableId || !viewId || !record) return;
await onInsertRecord(record.id, 'before');
},
},
{
type: MenuItemType.InsertBelow,
name: t('table:menu.insertRecordBelow'),
icon: <ArrowDown className={iconClassName} />,
hidden: records.length !== 1 || !permission['record|create'],
hidden: isMultipleSelected || !permission['record|create'],
disabled: isAutoSort,
onClick: async () => {
if (!tableId || !viewId) return;
await onInsertRecord(records[0].id, 'after');
if (!tableId || !viewId || !record) return;
await onInsertRecord(record.id, 'after');
},
},
],
[
{
type: MenuItemType.Delete,
name:
records.length > 1
? t('table:menu.deleteAllSelectedRecords')
: t('table:menu.deleteRecord'),
name: isMultipleSelected
? t('table:menu.deleteAllSelectedRecords')
: t('table:menu.deleteRecord'),
icon: <Trash className={iconClassName} />,
hidden: !permission['record|delete'],
className: 'text-red-500 aria-selected:text-red-500',
onClick: async () => {
const recordIds = records.map((r) => r.id);
tableId && (await deleteRecords(tableId, recordIds));
if (recordMenu && tableId && recordMenu.deleteRecords && selection) {
await recordMenu.deleteRecords(selection);
}
},
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useMutation } from '@tanstack/react-query';
import type { IAttachmentCellValue, IFilter } from '@teable/core';
import { AttachmentFieldCore } from '@teable/core';
import type { ICopyVo, IPasteRo, IRangesRo } from '@teable/openapi';
import { clear, copy, paste, RangeType } from '@teable/openapi';
import { clear, copy, deleteSelection, paste, RangeType } from '@teable/openapi';
import type { CombinedSelection, IRecordIndexMap } from '@teable/sdk';
import {
SelectionRegionType,
Expand Down Expand Up @@ -114,6 +114,11 @@ export const useSelectionOperation = (props?: {
clear(tableId!, { ...clearRo, viewId, groupBy, filter, search }),
});

const { mutateAsync: deleteReq } = useMutation({
mutationFn: (deleteRo: IRangesRo) =>
deleteSelection(tableId!, { ...deleteRo, viewId, groupBy, filter, search }),
});

const { toast } = useToast();
const copyMethod = useCopy({ copyReq: copyReq || defaultCopyReq });

Expand Down Expand Up @@ -277,5 +282,26 @@ export const useSelectionOperation = (props?: {
[tableId, toast, viewId, clearReq]
);

return { copy: doCopy, paste: doPaste, clear: doClear };
const doDelete = useCallback(
async (selection: CombinedSelection) => {
if (!viewId || !tableId) {
return;
}
const toaster = toast({
title: 'Deleting...',
});
const ranges = selection.serialize();
const type = rangeTypes[selection.type];

await deleteReq({
ranges,
...(type ? { type } : {}),
});

toaster.update({ id: toaster.id, title: 'Delete success!' });
},
[deleteReq, tableId, toast, viewId]
);

return { copy: doCopy, paste: doPaste, clear: doClear, deleteRecords: doDelete };
};
10 changes: 6 additions & 4 deletions apps/nextjs-app/src/features/app/blocks/view/grid/store/type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IPosition, IRectangle } from '@teable/sdk/components';
import type { CombinedSelection, IPosition, IRectangle } from '@teable/sdk/components';
import type { IFieldInstance, Record } from '@teable/sdk/model';

export interface IHeaderMenu {
Expand All @@ -8,10 +8,12 @@ export interface IHeaderMenu {
}

export interface IRecordMenu {
fields: IFieldInstance[];
records: Record[];
neighborRecords: (Record | null)[];
// only single select record
record?: Record;
neighborRecords?: (Record | null)[];
isMultipleSelected?: boolean;
position: IPosition;
deleteRecords?: (selection: CombinedSelection) => Promise<void>;
onAfterInsertCallback?: (recordId: string, targetIndex?: number) => void;
}

Expand Down
Loading

0 comments on commit a30bdf6

Please sign in to comment.