Skip to content

Commit 1258c8e

Browse files
authored
feat: implement field deletion functionality with confirmation dialog and localization support (#1662)
1 parent 9c0aaf5 commit 1258c8e

22 files changed

Lines changed: 279 additions & 27 deletions

File tree

apps/nestjs-backend/src/features/field/field-calculate/field-converting-link.service.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Injectable, InternalServerErrorException } from '@nestjs/common';
2-
import type { ILinkCellValue, ILinkFieldOptions, IOtOperation } from '@teable/core';
2+
import type { FieldAction, ILinkCellValue, ILinkFieldOptions, IOtOperation } from '@teable/core';
33
import {
44
Relationship,
55
RelationshipRevert,
@@ -455,11 +455,16 @@ export class FieldConvertingLinkService {
455455

456456
async planResetLinkFieldLookupFieldId(
457457
lookupedTableId: string,
458-
lookupedField: IFieldInstance
458+
lookupedField: IFieldInstance,
459+
fieldAction: FieldAction
459460
): Promise<string[]> {
460-
if (PRIMARY_SUPPORTED_TYPES.has(lookupedField.type)) {
461+
if (fieldAction !== 'field|update' && fieldAction !== 'field|delete') {
461462
return [];
462463
}
464+
if (fieldAction === 'field|update' && PRIMARY_SUPPORTED_TYPES.has(lookupedField.type)) {
465+
return [];
466+
}
467+
463468
const prisma = this.prismaService.txClient();
464469

465470
const lookupedFieldId = lookupedField.id;

apps/nestjs-backend/src/features/field/field-calculate/field-view-sync.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ export class FieldViewSyncService {
132132
const fieldId = newField.id;
133133
const resetLinkFieldIds = await this.fieldConvertingLinkService.planResetLinkFieldLookupFieldId(
134134
tableId,
135-
newField
135+
newField,
136+
'field|update'
136137
);
137138

138139
if (isEmpty(resetLinkFieldIds)) {

apps/nestjs-backend/src/features/field/open-api/field-open-api.controller.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ export class FieldOpenApiController {
125125
return await this.fieldOpenApiService.updateField(tableId, fieldId, updateFieldRo);
126126
}
127127

128+
@Permissions('field|delete')
129+
@Delete(':fieldId/plan')
130+
async planDeleteField(@Param('tableId') tableId: string, @Param('fieldId') fieldId: string) {
131+
return await this.fieldOpenApiService.planDeleteField(tableId, fieldId);
132+
}
133+
128134
@Permissions('field|delete')
129135
@Delete(':fieldId')
130136
async deleteField(

apps/nestjs-backend/src/features/field/open-api/field-open-api.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ export class FieldOpenApiService {
8686
return await this.graphService.planFieldConvert(tableId, fieldId, updateFieldRo);
8787
}
8888

89+
async planDeleteField(tableId: string, fieldId: string) {
90+
return await this.graphService.planDeleteField(tableId, fieldId);
91+
}
92+
8993
async getFields(tableId: string, query: IGetFieldsQuery) {
9094
return await this.fieldService.getFieldsByQuery(tableId, {
9195
...query,

apps/nestjs-backend/src/features/graph/graph.service.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
IGraphCombo,
99
IPlanFieldVo,
1010
IPlanFieldConvertVo,
11+
IPlanFieldDeleteVo,
1112
} from '@teable/openapi';
1213
import { Knex } from 'knex';
1314
import { groupBy, keyBy, uniq } from 'lodash';
@@ -351,7 +352,11 @@ export class GraphService {
351352
);
352353

353354
const resetLinkFieldLookupFieldIds =
354-
await this.fieldConvertingLinkService.planResetLinkFieldLookupFieldId(tableId, newField);
355+
await this.fieldConvertingLinkService.planResetLinkFieldLookupFieldId(
356+
tableId,
357+
newField,
358+
'field|update'
359+
);
355360

356361
return {
357362
graph,
@@ -361,6 +366,23 @@ export class GraphService {
361366
};
362367
}
363368

369+
async planDeleteField(tableId: string, fieldId: string): Promise<IPlanFieldDeleteVo> {
370+
const res = await this.planField(tableId, fieldId);
371+
const field = await this.fieldService.getField(tableId, fieldId);
372+
const fieldInstance = createFieldInstanceByVo(field);
373+
const resetLinkFieldLookupFieldIds =
374+
await this.fieldConvertingLinkService.planResetLinkFieldLookupFieldId(
375+
tableId,
376+
fieldInstance,
377+
'field|delete'
378+
);
379+
380+
return {
381+
...res,
382+
linkFieldCount: resetLinkFieldLookupFieldIds.length,
383+
};
384+
}
385+
364386
private async affectedCellCount(
365387
hostFieldId: string,
366388
fieldIds: string[],

apps/nextjs-app/src/features/app/blocks/graph/FieldGraph.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { IFieldRo } from '@teable/core';
1+
import type { FieldAction, IFieldRo } from '@teable/core';
22
import { ColorUtils } from '@teable/core';
33
import { useLanDayjs } from '@teable/sdk/hooks';
44
import { Badge } from '@teable/ui-lib/shadcn';
@@ -14,7 +14,12 @@ import { usePlan } from './usePlan';
1414
dayjs.extend(duration);
1515
dayjs.extend(relativeTime);
1616

17-
export const FieldGraph = (params: { tableId: string; fieldId?: string; fieldRo?: IFieldRo }) => {
17+
export const FieldGraph = (params: {
18+
tableId: string;
19+
fieldId?: string;
20+
fieldRo?: IFieldRo;
21+
fieldAction?: FieldAction;
22+
}) => {
1823
const ref = useRef(null);
1924
const planData = usePlan(params);
2025
const updateCellCount = planData?.updateCellCount;
Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,59 @@
11
import { useQuery } from '@tanstack/react-query';
2-
import type { IFieldRo } from '@teable/core';
3-
import { planField, planFieldCreate, planFieldConvert } from '@teable/openapi';
2+
import type { FieldAction, IFieldRo } from '@teable/core';
3+
import { planField, planFieldCreate, planFieldConvert, planFieldDelete } from '@teable/openapi';
44
import { ReactQueryKeys } from '@teable/sdk/config';
55

66
export function usePlan({
77
tableId,
88
fieldId,
99
fieldRo,
10+
fieldAction,
1011
}: {
1112
tableId: string;
1213
fieldId?: string;
1314
fieldRo?: IFieldRo;
15+
fieldAction?: FieldAction;
1416
}) {
17+
// if fieldAction is not provided, we need to infer it from fieldId and fieldRo
18+
let action = fieldAction;
19+
if (!action && fieldId && fieldRo) {
20+
action = 'field|update';
21+
}
22+
if (!action && !fieldId && fieldRo) {
23+
action = 'field|create';
24+
}
25+
if (!action && fieldId && !fieldRo) {
26+
action = 'field|read';
27+
}
28+
29+
const { data: deletePlan } = useQuery({
30+
queryKey: ReactQueryKeys.planFieldDelete(tableId, fieldId as string),
31+
queryFn: ({ queryKey }) => planFieldDelete(queryKey[1], queryKey[2]).then((data) => data.data),
32+
refetchOnWindowFocus: false,
33+
enabled: action === 'field|delete',
34+
});
35+
1536
const { data: updatePlan } = useQuery({
1637
queryKey: ReactQueryKeys.planFieldConvert(tableId, fieldId as string, fieldRo as IFieldRo),
1738
queryFn: ({ queryKey }) =>
1839
planFieldConvert(queryKey[1], queryKey[2], queryKey[3]).then((data) => data.data),
1940
refetchOnWindowFocus: false,
20-
enabled: !!(fieldId && fieldRo),
41+
enabled: action === 'field|update',
2142
});
2243

2344
const { data: createPlan } = useQuery({
2445
queryKey: ReactQueryKeys.planFieldCreate(tableId, fieldRo as IFieldRo),
2546
queryFn: ({ queryKey }) => planFieldCreate(queryKey[1], queryKey[2]).then((data) => data.data),
2647
refetchOnWindowFocus: false,
27-
enabled: !!(!fieldId && fieldRo),
48+
enabled: action === 'field|create',
2849
});
2950

3051
const { data: staticPlan } = useQuery({
3152
queryKey: ReactQueryKeys.planField(tableId, fieldId as string),
3253
queryFn: ({ queryKey }) => planField(queryKey[1], queryKey[2]).then((data) => data.data),
3354
refetchOnWindowFocus: false,
34-
enabled: !!(fieldId && !fieldRo),
55+
enabled: action === 'field|read',
3556
});
3657

37-
return createPlan || staticPlan || updatePlan;
58+
return deletePlan || updatePlan || createPlan || staticPlan;
3859
}

apps/nextjs-app/src/features/app/blocks/view/grid/components/FieldMenu.tsx

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
MagicAi,
1717
} from '@teable/icons';
1818
import type { IDuplicateFieldRo } from '@teable/openapi';
19-
import { deleteFields, duplicateField } from '@teable/openapi';
19+
import { duplicateField } from '@teable/openapi';
2020
import type { GridView } from '@teable/sdk';
2121
import {
2222
useFieldPermission,
@@ -44,8 +44,9 @@ import {
4444
SheetHeader,
4545
} from '@teable/ui-lib/shadcn';
4646
import { useTranslation } from 'next-i18next';
47-
import { Fragment, useRef } from 'react';
47+
import { Fragment, useEffect, useRef, useState } from 'react';
4848
import { useClickAway } from 'react-use';
49+
import { FieldDeleteConfirmDialog } from '@/features/app/components/field-setting/field-delete-confirm-dialog/FieldDeleteConfirmDialog';
4950
import { FieldOperator } from '@/features/app/components/field-setting/type';
5051
import { tableConfig } from '@/features/i18n/table.config';
5152
import { useFieldSettingStore } from '../../field/useFieldSettingStore';
@@ -82,6 +83,14 @@ export const FieldMenu = () => {
8283
const fieldSettingRef = useRef<HTMLDivElement>(null);
8384
const { fields, aiEnable, onSelectionClear, onAutoFill } = headerMenu ?? {};
8485
const { filterRef, sortRef, groupRef } = useToolBarStore();
86+
const emptyFieldMenu = !view || !fields?.length || !allFields.length;
87+
const [deleteFieldDialog, setDeleteFieldDialog] = useState<{
88+
open: boolean;
89+
tableId?: string;
90+
fieldIds?: string[];
91+
}>({
92+
open: false,
93+
});
8594

8695
const { mutateAsync: duplicateFieldFn } = useMutation({
8796
mutationFn: ({
@@ -99,7 +108,15 @@ export const FieldMenu = () => {
99108
closeHeaderMenu();
100109
});
101110

102-
if (!view || !fields?.length || !allFields.length) return null;
111+
useEffect(() => {
112+
if (emptyFieldMenu) {
113+
setDeleteFieldDialog({ open: false });
114+
}
115+
}, [emptyFieldMenu]);
116+
117+
if (emptyFieldMenu) {
118+
return null;
119+
}
103120

104121
const fieldIds = fields.map((f) => f.id);
105122

@@ -350,10 +367,12 @@ export const FieldMenu = () => {
350367
const fieldIdsSet = new Set(fieldIds);
351368
const filteredFields = allFields.filter((f) => fieldIdsSet.has(f.id)).filter(Boolean);
352369
if (filteredFields.length === 0) return;
353-
await deleteFields(
370+
371+
setDeleteFieldDialog({
372+
open: true,
354373
tableId,
355-
filteredFields.map((f) => f.id)
356-
);
374+
fieldIds: filteredFields.map((f) => f.id),
375+
});
357376
},
358377
},
359378
],
@@ -381,8 +400,11 @@ export const FieldMenu = () => {
381400
if (disabled) return;
382401

383402
await onClick();
384-
onSelectionClear?.();
385-
closeHeaderMenu();
403+
// Don't auto-close menu for delete action
404+
if (type !== MenuItemType.Delete) {
405+
onSelectionClear?.();
406+
closeHeaderMenu();
407+
}
386408
}}
387409
>
388410
{icon}
@@ -424,8 +446,11 @@ export const FieldMenu = () => {
424446
return;
425447
}
426448
await onClick();
427-
onSelectionClear?.();
428-
closeHeaderMenu();
449+
// Don't auto-close menu for delete action
450+
if (type !== MenuItemType.Delete) {
451+
onSelectionClear?.();
452+
closeHeaderMenu();
453+
}
429454
}}
430455
>
431456
{icon}
@@ -442,6 +467,17 @@ export const FieldMenu = () => {
442467
</PopoverContent>
443468
</Popover>
444469
)}
470+
471+
<FieldDeleteConfirmDialog
472+
tableId={deleteFieldDialog.tableId ?? ''}
473+
fieldIds={deleteFieldDialog.fieldIds ?? []}
474+
open={deleteFieldDialog.open}
475+
onClose={() => {
476+
setDeleteFieldDialog({ open: false });
477+
onSelectionClear?.();
478+
closeHeaderMenu();
479+
}}
480+
/>
445481
</>
446482
);
447483
};
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { deleteFields } from '@teable/openapi';
2+
import { useFields, useFieldStaticGetter } from '@teable/sdk/hooks';
3+
import { ConfirmDialog } from '@teable/ui-lib/base';
4+
import { Button } from '@teable/ui-lib/shadcn';
5+
import { cn } from '@teable/ui-lib/shadcn/utils';
6+
import { first } from 'lodash';
7+
import { useTranslation } from 'next-i18next';
8+
import { useState } from 'react';
9+
import { DynamicFieldGraph } from '@/features/app/blocks/graph/DynamicFieldGraph';
10+
11+
interface FieldDeleteConfirmDialogProps {
12+
open: boolean;
13+
tableId: string;
14+
fieldIds: string[];
15+
onClose?: () => void;
16+
}
17+
18+
const FieldGraphListPanel = (props: { tableId: string; fieldIds: string[] }) => {
19+
const { tableId, fieldIds } = props;
20+
const fieldStaticGetter = useFieldStaticGetter();
21+
const allFields = useFields();
22+
const fields = allFields.filter((field) => fieldIds.includes(field.id));
23+
const [activeFieldId, setActiveFieldId] = useState(first(fields)?.id);
24+
return (
25+
<>
26+
<div className="w-full">
27+
{fields.map((field) => {
28+
const { Icon } = fieldStaticGetter(field.type, {
29+
isLookup: field.isLookup,
30+
hasAiConfig: Boolean(field.aiConfig),
31+
deniedReadRecord: !field.canReadFieldRecord,
32+
});
33+
return (
34+
<Button
35+
key={field.id}
36+
variant={'ghost'}
37+
size={'xs'}
38+
className={cn('font-normal shrink-0 truncate', {
39+
'bg-secondary': activeFieldId === field.id,
40+
})}
41+
onClick={() => setActiveFieldId(field.id)}
42+
>
43+
<Icon className="size-4 text-sm" />
44+
<span className={cn('truncate max-w-32')}>{field.name}</span>
45+
</Button>
46+
);
47+
})}
48+
</div>
49+
<DynamicFieldGraph fieldId={activeFieldId} tableId={tableId} fieldAction="field|delete" />
50+
</>
51+
);
52+
};
53+
54+
export const FieldDeleteConfirmDialog = (props: FieldDeleteConfirmDialogProps) => {
55+
const { tableId, fieldIds, open, onClose } = props;
56+
const { t } = useTranslation(['common', 'table']);
57+
58+
const close = () => {
59+
onClose?.();
60+
};
61+
62+
const actionDelete = async () => {
63+
await deleteFields(tableId, fieldIds);
64+
close();
65+
};
66+
67+
return (
68+
<ConfirmDialog
69+
contentClassName="max-w-6xl"
70+
title={t('table:table.actionTips.deleteFieldConfirmTitle')}
71+
open={open}
72+
onOpenChange={(open) => {
73+
if (!open) {
74+
onClose?.();
75+
}
76+
}}
77+
content={
78+
<>
79+
<FieldGraphListPanel tableId={tableId} fieldIds={fieldIds} />
80+
</>
81+
}
82+
cancelText={t('common:actions.cancel')}
83+
confirmText={t('common:actions.confirm')}
84+
onCancel={close}
85+
onConfirm={actionDelete}
86+
/>
87+
);
88+
};

0 commit comments

Comments
 (0)