feat(fields): мягкое удаление полей формы — архив удалённых с восстановлением и блок «Удалённые поля» в задаче
This commit is contained in:
110
client/src/components/form-editor/DeletedFieldsDialog.tsx
Normal file
110
client/src/components/form-editor/DeletedFieldsDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Archive, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getFieldTypeDef } from '@shared/field-types';
|
||||
|
||||
interface DeletedField {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Архив удалённых (мягко) полей формы.
|
||||
* Удалённые поля скрыты везде, но их определение и значения в задачах сохранены —
|
||||
* отсюда их можно восстановить, и значения вернутся во все задачи автоматически.
|
||||
*/
|
||||
export function DeletedFieldsDialog({ formId }: { formId: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery<{ success: boolean; fields: DeletedField[] }>({
|
||||
queryKey: ['/api/forms', formId, 'fields', 'deleted'],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest('GET', `/api/forms/${formId}/fields/deleted`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: async (fieldId: number) => {
|
||||
const res = await apiRequest('POST', `/api/forms/${formId}/fields/${fieldId}/restore`);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Поле восстановлено', description: 'Значения в задачах сохранились и снова видны' });
|
||||
refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'editor'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({ title: 'Не удалось восстановить', description: error?.message || 'Ошибка', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
const fields = data?.fields || [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" data-testid="button-deleted-fields">
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
Удалённые поля
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Удалённые поля</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Удалённые поля скрыты в форме, но их значения в задачах сохранены. Восстановление вернёт поле и все его значения.
|
||||
</p>
|
||||
<div className="max-h-72 overflow-auto space-y-1.5 py-1">
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-6">Загрузка…</p>
|
||||
) : fields.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-6">Нет удалённых полей</p>
|
||||
) : (
|
||||
fields.map((field) => (
|
||||
<div key={field.id} className="flex items-center gap-2 rounded border px-2 py-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{field.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{getFieldTypeDef(field.type)?.label || field.type} · {field.code}
|
||||
{field.deletedAt ? ` · удалено ${new Date(field.deletedAt).toLocaleDateString('ru-RU')}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">удалено</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
title="Восстановить"
|
||||
disabled={restoreMutation.isPending}
|
||||
onClick={() => restoreMutation.mutate(field.id)}
|
||||
data-testid={`button-restore-field-${field.id}`}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { DndContext, closestCenter } from '@dnd-kit/core';
|
||||
import { DeletedFieldsDialog } from './DeletedFieldsDialog';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Plus, Trash2, ChevronDown, Table, Layers } from 'lucide-react';
|
||||
import { TabRenderer } from '@/components/tabs';
|
||||
@@ -101,6 +102,7 @@ export function FieldsTab({
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{id && <DeletedFieldsDialog formId={Number(id)} />}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" data-testid="button-add-tab">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { FieldValueRow } from '@/components/task-fields/FieldValueRow';
|
||||
import type { FormFieldData, TaskData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow';
|
||||
import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
|
||||
import { getSelectOptionLabel } from '@/lib/fieldOptions';
|
||||
import type { UserSummary } from './types';
|
||||
|
||||
interface TabFieldsContentProps {
|
||||
@@ -21,6 +22,29 @@ interface TabFieldsContentProps {
|
||||
isFieldImmutableForTask: (field: FormFieldData) => boolean;
|
||||
fieldErrors: Record<number, string>;
|
||||
setFieldErrors: React.Dispatch<React.SetStateAction<Record<number, string>>>;
|
||||
/** Удалённые (мягко) поля формы — архив со значениями внизу вкладки */
|
||||
deletedFields?: FormFieldData[];
|
||||
}
|
||||
|
||||
/** Read-only отображение значения удалённого поля (без редакторов и запросов) */
|
||||
function formatDeletedValue(field: FormFieldData, value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(v =>
|
||||
typeof v === 'object' && v !== null
|
||||
? String((v as Record<string, unknown>).name ?? (v as Record<string, unknown>).label ?? JSON.stringify(v))
|
||||
: String(v)
|
||||
);
|
||||
return items.join(', ') || '—';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
return String(obj.name ?? obj.label ?? obj.address ?? JSON.stringify(value));
|
||||
}
|
||||
if (field.type === 'select' || field.type === 'radio-group' || field.type === 'checklist') {
|
||||
return getSelectOptionLabel(field.options, value as string) || String(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export const TabFieldsContent = ({
|
||||
@@ -40,11 +64,16 @@ export const TabFieldsContent = ({
|
||||
isFieldImmutableForTask,
|
||||
fieldErrors,
|
||||
setFieldErrors,
|
||||
deletedFields,
|
||||
}: TabFieldsContentProps) => {
|
||||
const { isPending: isUrlPending, getPendingBody } = useOfflineSyncContext();
|
||||
const visibleFields = fields.filter(
|
||||
f => f.type !== 'company' && (tabId === undefined || f.tabId === tabId) && isFieldVisibleForTask(f)
|
||||
);
|
||||
// Удалённые поля с сохранёнными значениями — архив внизу основной вкладки полей
|
||||
const deletedWithValues = (deletedFields ?? [])
|
||||
.map(field => ({ field, value: fieldValues.find(fv => fv.fieldId === field.id)?.value }))
|
||||
.filter(({ value }) => value !== undefined && value !== null && value !== '');
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
{visibleFields && visibleFields.length > 0 ? (
|
||||
@@ -85,6 +114,24 @@ export const TabFieldsContent = ({
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
{tabId === undefined && deletedWithValues.length > 0 && (
|
||||
<details className="rounded-md border border-dashed mt-4">
|
||||
<summary className="cursor-pointer px-3 py-2 text-xs text-muted-foreground select-none">
|
||||
Удалённые поля ({deletedWithValues.length})
|
||||
</summary>
|
||||
<div className="px-3 pb-3 pt-1 space-y-1.5">
|
||||
{deletedWithValues.map(({ field, value }) => (
|
||||
<div key={field.id} className="flex items-start gap-2 text-xs">
|
||||
<span className="text-muted-foreground min-w-[140px] shrink-0 truncate" title={field.name}>
|
||||
{field.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 text-[10px] shrink-0 pt-0.5">удалено</span>
|
||||
<span className="text-muted-foreground/80 break-all">{formatDeletedValue(field, value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface TaskContentProps {
|
||||
isFieldImmutableForTask: (field: FormFieldData) => boolean;
|
||||
fieldErrors: Record<number, string>;
|
||||
setFieldErrors: React.Dispatch<React.SetStateAction<Record<number, string>>>;
|
||||
/** Удалённые (мягко) поля формы — архив со значениями внизу вкладки полей */
|
||||
deletedFields?: FormFieldData[];
|
||||
modulesData?: { modules: TabModuleInfo[] };
|
||||
subtasks: Array<TaskData & { formId?: number }>;
|
||||
formNamesMap: Record<number, string>;
|
||||
@@ -64,6 +66,7 @@ export function TaskContent({
|
||||
isFieldImmutableForTask,
|
||||
fieldErrors,
|
||||
setFieldErrors,
|
||||
deletedFields,
|
||||
modulesData,
|
||||
subtasks,
|
||||
formNamesMap,
|
||||
@@ -233,6 +236,7 @@ export function TaskContent({
|
||||
isFieldImmutableForTask={isFieldImmutableForTask}
|
||||
fieldErrors={fieldErrors}
|
||||
setFieldErrors={setFieldErrors}
|
||||
deletedFields={deletedFields}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -33,6 +33,8 @@ interface TaskMobileAccordionProps {
|
||||
isFieldImmutableForTask: (field: FormFieldData) => boolean;
|
||||
fieldErrors: Record<number, string>;
|
||||
setFieldErrors: React.Dispatch<React.SetStateAction<Record<number, string>>>;
|
||||
/** Удалённые (мягко) поля формы — архив со значениями */
|
||||
deletedFields?: FormFieldData[];
|
||||
orderedSystemTabs: string[];
|
||||
subtasks: Array<TaskData & { formId?: number }>;
|
||||
formNamesMap: Record<number, string>;
|
||||
@@ -61,6 +63,7 @@ export function TaskMobileAccordion({
|
||||
isFieldImmutableForTask,
|
||||
fieldErrors,
|
||||
setFieldErrors,
|
||||
deletedFields,
|
||||
orderedSystemTabs,
|
||||
subtasks,
|
||||
formNamesMap,
|
||||
@@ -107,6 +110,7 @@ export function TaskMobileAccordion({
|
||||
isFieldImmutableForTask={isFieldImmutableForTask}
|
||||
fieldErrors={fieldErrors}
|
||||
setFieldErrors={setFieldErrors}
|
||||
deletedFields={deletedFields}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -99,6 +99,8 @@ export interface TaskDetailResponse extends CachedTaskDetail {
|
||||
messages: unknown[];
|
||||
users: UserSummary[];
|
||||
hasRelations: boolean;
|
||||
/** Удалённые (мягко) поля формы — архив со значениями в fieldValues */
|
||||
deletedFields?: FormFieldData[];
|
||||
}
|
||||
|
||||
export interface TaskRoleData {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
|
||||
import { useTaskWithCache } from '@/hooks/useTaskWithCache';
|
||||
import { saveTask, patchTaskCache } from '@/lib/tasksCache';
|
||||
import type { Task } from '@shared/schema';
|
||||
import { computeTaskTagColor } from '@shared/tag-color';
|
||||
import { evaluateAllFormulas } from '@/lib/formulaEngine';
|
||||
import { TaskContent } from '@/components/task-detail/TaskContent';
|
||||
import { TaskDetailHeader } from '@/components/task-detail/TaskDetailHeader';
|
||||
@@ -536,16 +535,6 @@ const TaskDetail = () => {
|
||||
rawFieldValuesMap,
|
||||
);
|
||||
|
||||
// «Главный» цветной тег задачи (первая цветная опция select-поля) — чип в шапке
|
||||
const tagValuesMap: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const fv = fieldValues.find((v: TaskFieldValueData) => v.fieldId === field.id);
|
||||
if (fv?.value !== undefined && fv?.value !== null && fv?.value !== '') {
|
||||
tagValuesMap[`field_${field.id}`] = fv.value;
|
||||
}
|
||||
}
|
||||
const taskTag = computeTaskTagColor(fields, tagValuesMap);
|
||||
|
||||
// Кастомный вид задачи: пресет (@preset:<id>) или JS-код
|
||||
const hasCustomLayout = detailLayoutInfo.kind !== 'standard';
|
||||
const allTransitionsForLayout: FormTransition[] = effectiveDetailData?.transitions || [];
|
||||
@@ -615,6 +604,7 @@ const TaskDetail = () => {
|
||||
isFieldImmutableForTask={isFieldImmutableForTask}
|
||||
fieldErrors={fieldErrors}
|
||||
setFieldErrors={setFieldErrors}
|
||||
deletedFields={effectiveDetailData?.deletedFields || []}
|
||||
modulesData={modulesData}
|
||||
subtasks={subtasks}
|
||||
formNamesMap={formNamesMap}
|
||||
@@ -690,6 +680,7 @@ const TaskDetail = () => {
|
||||
isFieldImmutableForTask={isFieldImmutableForTask}
|
||||
fieldErrors={fieldErrors}
|
||||
setFieldErrors={setFieldErrors}
|
||||
deletedFields={effectiveDetailData?.deletedFields || []}
|
||||
orderedSystemTabs={orderedSystemTabs}
|
||||
subtasks={subtasks}
|
||||
formNamesMap={formNamesMap}
|
||||
@@ -743,7 +734,6 @@ const TaskDetail = () => {
|
||||
toggleHideMutation={toggleHideMutation}
|
||||
onShowReminderDialog={setShowReminderDialog}
|
||||
onShowDeleteDialog={setShowDeleteDialog}
|
||||
tag={taskTag}
|
||||
/>
|
||||
|
||||
<TaskTabsBar
|
||||
|
||||
Reference in New Issue
Block a user