Skip to content

Commit ea1867e

Browse files
committed
feat: dnd with overlay and sortable in drag element
1 parent 6d10d17 commit ea1867e

14 files changed

+517
-74
lines changed

DECISIONS.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,34 +46,34 @@ Se usa **JSONB** para dos columnas clave:
4646

4747
---
4848

49-
## 3. Frontend: Arquitectura de Componentes por Features (autocontenidos)
49+
## 3. Frontend: Vertical Slicing (Arquitectura por Features)
5050

5151
### Decisión
5252

53-
El frontend sigue una **arquitectura de componentes por feature**, donde cada feature (auth, dashboard, forms, builder, theme) es **autocontenido**: tiene su propio `index.ts` como API pública, y agrupa dentro de sí componentes, hooks, contexto y schemas. No hay carpeta global `components/hooks`; cada feature lleva sus hooks en una subcarpeta `hooks/` si los necesita (ej. `builder/hooks/`).
53+
El frontend sigue **vertical slicing** (también conocido como **feature-based architecture** o **slice-by-feature**): cada **slice** o feature (auth, dashboard, forms, builder, theme) es una **unidad vertical autocontenida** con su propio `index.ts` como API pública, y agrupa dentro de sí componentes, hooks, contexto y schemas. No existe carpeta global `components/hooks`; cada feature lleva sus hooks en una subcarpeta `hooks/` si los necesita (ej. `builder/hooks/`).
5454

55-
Estructura actual:
55+
Estructura actual (cada carpeta bajo `components/` es un slice vertical):
5656

5757
```
5858
components/
5959
auth/ → AuthForm, auth-form-schema (index exporta la API)
6060
builder/ → form-builder, context, schema + layout/, fields/, preview/, hooks/
61-
dashboard/ → Navbar, FormCard, EmptyState, ResponseTable, etc. + charts/
62-
forms/ → PublicForm, FieldRenderer, response-schema, form-defaults
61+
dashboard/ → Navbar, FormCard, EmptyState, ResponseTable, etc. + charts/, list/, layout/, responses/
62+
forms/ → PublicForm, FieldRenderer, response-schema, form-defaults (public/, renderer/)
6363
theme/ → ThemeProvider, ThemeToggle
6464
ui/ → Primitivos shadcn (sin index; import por archivo)
6565
```
6666

67-
Las páginas importan desde el barrel del feature: `import { FormBuilder } from '@/components/builder'`, `import { AuthForm } from '@/components/auth'`. La convención completa está en el [README principal](./README.md#convenciones-de-componentes-frontend).
67+
Las páginas importan desde el barrel del feature: `import { FormBuilder } from '@/components/builder'`, `import { AuthForm } from '@/components/auth'`. La convención completa está en el [README](./README.md#convenciones-de-componentes-vertical-slicing).
6868

6969
### Justificación
7070

71-
- **Colocación por feature**: Todo lo que pertenece a un flujo (login, builder, dashboard) vive junto. Cambios en el builder no dispersan archivos por `components/` y `hooks/`; todo está en `builder/`.
72-
- **API pública clara**: El `index.ts` de cada feature define qué se expone al resto de la app. El resto son detalles internos del feature (layout, campos, hooks), lo que reduce acoplamiento y facilita refactors.
73-
- **Hooks por feature, no globales**: Evitamos un cajón de sastre `src/hooks/` con lógica de un solo feature. Cada feature lleva sus hooks (use-form-builder, use-form-builder-dnd, use-form-builder-save en `builder/hooks/`). Solo hooks de utilidad realmente reutilizables (useDebounce, useMediaQuery) tendrían sentido en `src/hooks/` si se añaden.
74-
- **Escalabilidad**: Si un feature crece (como el builder), se subdivide en subcarpetas (layout, fields, preview, hooks) con sus propios `index.ts`, sin contaminar la raíz de `components/`.
71+
- **Vertical slicing**: Cada slice corta “en vertical” la capa de presentación: todo lo que pertenece a un flujo (login, builder, dashboard) vive junto. Cambios en el builder no dispersan archivos por `components/` y `hooks/`; todo está en `builder/`.
72+
- **API pública por slice**: El `index.ts` de cada feature define qué se expone al resto de la app. El resto son detalles internos del slice (layout, campos, hooks), lo que reduce acoplamiento y facilita refactors.
73+
- **Hooks por feature, no globales**: Evitamos un cajón de sastre `src/hooks/` con lógica de un solo feature. Cada slice lleva sus hooks (use-form-builder, use-form-builder-dnd, use-form-builder-save en `builder/hooks/`). Solo hooks de utilidad realmente reutilizables (useDebounce, useMediaQuery) tendrían sentido en `src/hooks/` si se añaden.
74+
- **Escalabilidad**: Si un slice crece (como el builder), se subdivide en subcarpetas (layout, fields, preview, hooks) con sus propios `index.ts`, sin contaminar la raíz de `components/`.
7575
- **Reutilización controlada**: `FieldRenderer` se usa en el preview del builder y en el formulario público; se exporta desde `@/components/forms` y el builder lo importa desde ahí, manteniendo una única fuente de verdad.
76-
- **Composición**: Componentes pequeños y enfocados que se componen por feature; la app orquesta features, no decenas de componentes sueltos.
76+
- **Composición**: Componentes pequeños y enfocados que se componen por feature; la app orquesta slices, no decenas de componentes sueltos.
7777

7878
---
7979

README.md

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -79,30 +79,30 @@ src/
7979
│ ├── adapters/supabase/ # Implementación Supabase
8080
│ └── mappers/ # Transformación Domain ↔ DB
8181
82-
├── components/ # 🎨 Componentes React (ver convenciones abajo)
83-
│ ├── auth/ # Login/registro
84-
│ ├── builder/ # Editor de formularios (layout, fields, preview, hooks)
85-
│ ├── dashboard/ # Listado, detalle, gráficas
86-
│ ├── forms/ # Formulario público y renderer de campos
87-
│ ├── theme/ # ThemeProvider, ThemeToggle
88-
│ └── ui/ # shadcn/ui (primitivos)
82+
├── components/ # 🎨 Vertical slicing (por feature)
83+
│ ├── auth/ # Slice: login/registro
84+
│ ├── builder/ # Slice: editor (layout, fields, preview, hooks)
85+
│ ├── dashboard/ # Slice: listado, detalle, gráficas
86+
│ ├── forms/ # Slice: formulario público y renderer
87+
│ ├── theme/ # Slice: ThemeProvider, ThemeToggle
88+
│ └── ui/ # Primitivos shadcn (sin slice; import por archivo)
8989
9090
└── lib/ # Utilidades (Supabase client, cn)
9191
```
9292

93-
### Convenciones de componentes (frontend)
93+
### Convenciones de componentes (vertical slicing)
9494

95-
Cada **feature** (auth, dashboard, forms, builder, theme) es **autocontenido**: tiene su propio `index.ts` (API pública), y opcionalmente subcarpetas por responsabilidad. La app importa desde el barrel del feature, no por archivo.
95+
El frontend usa **vertical slicing** (arquitectura por features): cada **slice** (auth, dashboard, forms, builder, theme) es una unidad vertical autocontenida con su propio `index.ts` (API pública) y, opcionalmente, subcarpetas por responsabilidad. La app importa desde el barrel del slice, no por archivo. Detalle en [DECISIONS.md §3](./DECISIONS.md#3-frontend-vertical-slicing-arquitectura-por-features).
9696

97-
| Regla | Descripción |
98-
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
99-
| **API por feature** | `import { AuthForm } from '@/components/auth'`, `import { FormBuilder } from '@/components/builder'`, etc. |
100-
| **ui/** | Sin index; imports directos: `@/components/ui/button`, `@/components/ui/card` (patrón shadcn). |
101-
| **Subcarpetas** | Si un feature crece (ej. builder), se agrupa en `layout/`, `fields/`, `preview/`, `hooks/`, cada uno con su `index.ts`. |
102-
| **Schemas y contexto** | Pertenecen al feature: `auth-form-schema.ts`, `form-builder-context.tsx`, `form-builder-schema.ts`. |
103-
| **Hooks** | No hay `components/hooks` global. Cada feature lleva sus hooks dentro (ej. `builder/hooks/`). Solo hooks de utilidad reutilizables (useDebounce, useMediaQuery) irían en `src/hooks/` si se necesitan. |
104-
| **Naming** | Archivos en kebab-case; componentes/hooks en PascalCase/camelCase. |
105-
| **Tests** | `__tests__/` dentro del feature; imports relativos a archivos. |
97+
| Regla | Descripción |
98+
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
99+
| **API por slice** | `import { AuthForm } from '@/components/auth'`, `import { FormBuilder } from '@/components/builder'`, etc. |
100+
| **ui/** | Sin index; imports directos: `@/components/ui/button`, `@/components/ui/card` (patrón shadcn). |
101+
| **Subcarpetas** | Si un slice crece (ej. builder), se agrupa en `layout/`, `fields/`, `preview/`, `hooks/`, cada uno con su `index.ts`. |
102+
| **Schemas y contexto** | Pertenecen al slice: `auth-form-schema.ts`, `form-builder-context.tsx`, `form-builder-schema.ts`. |
103+
| **Hooks** | No hay `components/hooks` global. Cada slice lleva sus hooks dentro (ej. `builder/hooks/`). Hooks de utilidad reutilizables irían en `src/hooks/` si se necesitan. |
104+
| **Naming** | Archivos en kebab-case; componentes/hooks en PascalCase/camelCase. |
105+
| **Tests** | `__tests__/` dentro del slice; imports relativos a archivos. |
106106

107107
## Funcionalidades
108108

@@ -125,7 +125,7 @@ Cada **feature** (auth, dashboard, forms, builder, theme) es **autocontenido**:
125125
### Arquitectura
126126

127127
- ✅ Arquitectura Hexagonal (Clean Architecture) en backend
128-
- ✅ Arquitectura de Componentes en frontend
128+
- ✅ Arquitectura por componentes, estructura en vertical slicing (por features) en frontend
129129
- ✅ TypeScript estricto
130130
- ✅ Separación de responsabilidades
131131

jest.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ const config: Config = {
2020
preset: 'ts-jest',
2121
testEnvironment: 'jsdom',
2222
roots: ['<rootDir>/src'],
23-
testMatch: ['<rootDir>/src/components/**/__tests__/**/*.test.tsx'],
23+
testMatch: [
24+
'<rootDir>/src/components/**/__tests__/**/*.test.tsx',
25+
'<rootDir>/src/utils/**/__tests__/**/*.test.ts',
26+
],
2427
moduleNameMapper: {
2528
'^@/(.*)$': '<rootDir>/src/$1',
2629
},
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/** Min height of a field row; used for insertion spacer during drag. */
2+
export const FIELD_ROW_MIN_HEIGHT = 52;
3+
4+
/** Sortable transition config for smooth reorder animation. */
5+
export const SORTABLE_TRANSITION = {
6+
duration: 280,
7+
easing: 'cubic-bezier(0.33, 1, 0.68, 1)' as const,
8+
};
9+
10+
/** Minimum pointer movement (px) before starting drag. */
11+
export const DND_POINTER_ACTIVATION_DISTANCE = 6;

src/components/builder/fields/field-type-drag-overlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const FieldTypeDragOverlay: FC<FieldTypeDragOverlayProps> = ({
1919
const Icon = FIELD_TYPE_ICON_MAP[type];
2020

2121
return (
22-
<div className="flex items-center gap-2 rounded-lg border border-indigo-300 bg-white px-4 py-3 shadow-lg dark:border-indigo-700 dark:bg-gray-900">
22+
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-indigo-300 bg-white px-4 py-3 text-center shadow-lg dark:border-indigo-700 dark:bg-gray-900">
2323
<Icon className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
2424
<span className="text-sm font-medium">{FIELD_TYPE_LABELS[type]}</span>
2525
</div>

src/components/builder/fields/sortable-field-item.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { FormField } from '@/core/domain/entities/form';
1111
import { FIELD_TYPE_LABELS } from '@/core/domain/value-objects/field-types';
1212
import { cn } from '@/utils/cn';
1313

14+
import { SORTABLE_TRANSITION } from '../constants';
1415
import { useFormBuilderContext } from '../form-builder-context';
1516

1617
interface SortableFieldItemProps {
@@ -32,7 +33,10 @@ export const SortableFieldItem: FC<SortableFieldItemProps> = ({ field }) => {
3233
transform,
3334
transition,
3435
isDragging,
35-
} = useSortable({ id: field.id });
36+
} = useSortable({
37+
id: field.id,
38+
transition: SORTABLE_TRANSITION,
39+
});
3640

3741
const style = {
3842
transform: CSS.Transform.toString(transform),

src/components/builder/form-builder.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const FormBuilderInner: FC<{ formId?: string }> = ({ formId }) => {
4141
activeField,
4242
isDropTarget,
4343
dragWidth,
44+
insertionIndex,
4445
} = useFormBuilderDnD();
4546

4647
return (
@@ -62,6 +63,7 @@ const FormBuilderInner: FC<{ formId?: string }> = ({ formId }) => {
6263
<FormBuilderFieldsColumn
6364
activeTab={activeTab}
6465
isDropTarget={isDropTarget}
66+
insertionIndex={insertionIndex}
6567
/>
6668
<div
6769
className={`lg:col-span-4 ${

src/components/builder/hooks/use-form-builder-dnd.ts

Lines changed: 67 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,24 @@
22

33
import { useState, useCallback, useMemo } from 'react';
44
import {
5-
pointerWithin,
6-
closestCenter,
75
KeyboardSensor,
86
PointerSensor,
97
useSensor,
108
useSensors,
119
type DragEndEvent,
1210
type DragStartEvent,
1311
type DragOverEvent,
14-
type CollisionDetection,
1512
} from '@dnd-kit/core';
1613
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
1714

1815
import type { FieldType } from '@/core/domain/value-objects/field-types';
16+
import { createListCollisionDetection } from '@/utils/dnd/collision-detection';
17+
import {
18+
getOverId,
19+
resolveNewItemInsertionIndex,
20+
} from '@/utils/dnd/insertion-index';
1921

22+
import { DND_POINTER_ACTIVATION_DISTANCE } from '../constants';
2023
import { useFormBuilderContext } from '../form-builder-context';
2124
import { DROP_ZONE_ID } from '../fields';
2225

@@ -31,33 +34,31 @@ export function useFormBuilderDnD() {
3134
const [activeDragType, setActiveDragType] = useState<FieldType | null>(null);
3235
const [activeDragId, setActiveDragId] = useState<string | null>(null);
3336
const [overId, setOverId] = useState<string | null>(null);
37+
const [insertionIndex, setInsertionIndex] = useState<number | null>(null);
3438
const [dragWidth, setDragWidth] = useState<number | null>(null);
3539

3640
const sensors = useSensors(
37-
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
41+
useSensor(PointerSensor, {
42+
activationConstraint: { distance: DND_POINTER_ACTIVATION_DISTANCE },
43+
}),
3844
useSensor(KeyboardSensor, {
3945
coordinateGetter: sortableKeyboardCoordinates,
4046
}),
4147
);
4248

43-
const collisionDetection: CollisionDetection = useCallback((args) => {
44-
if (args.active.data.current?.isNewField) {
45-
const pointerCollisions = pointerWithin(args);
46-
if (pointerCollisions.length > 0) return pointerCollisions;
47-
return [];
48-
}
49-
return closestCenter({
50-
...args,
51-
droppableContainers: args.droppableContainers.filter(
52-
(c) => c.id !== DROP_ZONE_ID,
53-
),
54-
});
55-
}, []);
49+
const collisionDetection = useMemo(
50+
() =>
51+
createListCollisionDetection({
52+
dropZoneId: DROP_ZONE_ID,
53+
}),
54+
[],
55+
);
5656

5757
const clearDragState = useCallback(() => {
5858
setActiveDragType(null);
5959
setActiveDragId(null);
6060
setOverId(null);
61+
setInsertionIndex(null);
6162
setDragWidth(null);
6263
}, []);
6364

@@ -77,37 +78,67 @@ export function useFormBuilderDnD() {
7778
const handleDragOver = useCallback(
7879
(event: DragOverEvent) => {
7980
const { active, over } = event;
80-
setOverId(over?.id != null ? String(over.id) : null);
81-
82-
if (
83-
!active.data.current?.isNewField &&
84-
over &&
85-
active.id !== over.id &&
86-
over.id !== DROP_ZONE_ID
87-
) {
88-
reorderFields(String(active.id), String(over.id));
81+
const newOverId = getOverId(over);
82+
setOverId((prev) => (prev === newOverId ? prev : newOverId));
83+
84+
if (!active.data.current?.isNewField) {
85+
setInsertionIndex((prev) => (prev === null ? prev : null));
86+
return;
8987
}
88+
89+
const nextIndex = resolveNewItemInsertionIndex({
90+
overId: newOverId,
91+
overRect: over?.rect ?? null,
92+
activeRect: active.rect.current.translated,
93+
items: fields,
94+
dropZoneId: DROP_ZONE_ID,
95+
});
96+
setInsertionIndex((prev) => (prev === nextIndex ? prev : nextIndex));
9097
},
91-
[reorderFields],
98+
[fields],
9299
);
93100

94101
const handleDragEnd = useCallback(
95102
(event: DragEndEvent) => {
96103
const { active, over } = event;
97-
clearDragState();
98-
if (!over) return;
104+
const overId = getOverId(over);
105+
106+
if (overId == null || !over) {
107+
clearDragState();
108+
return;
109+
}
99110

100111
if (active.data.current?.isNewField) {
101112
const type = active.data.current.type as FieldType;
102-
if (over.id === DROP_ZONE_ID) {
103-
addField(type);
104-
} else {
105-
const overIndex = fields.findIndex((f) => f.id === over.id);
106-
if (overIndex !== -1) addFieldAt(type, overIndex);
113+
const targetIndex =
114+
insertionIndex != null
115+
? Math.max(0, Math.min(insertionIndex, fields.length))
116+
: resolveNewItemInsertionIndex({
117+
overId,
118+
overRect: over.rect,
119+
activeRect: active.rect.current.translated,
120+
items: fields,
121+
dropZoneId: DROP_ZONE_ID,
122+
});
123+
124+
if (targetIndex != null) {
125+
if (targetIndex >= fields.length) addField(type);
126+
else addFieldAt(type, targetIndex);
107127
}
128+
} else if (overId !== DROP_ZONE_ID && overId !== String(active.id)) {
129+
reorderFields(String(active.id), overId);
108130
}
131+
132+
clearDragState();
109133
},
110-
[addField, addFieldAt, fields, clearDragState],
134+
[
135+
addField,
136+
addFieldAt,
137+
fields,
138+
insertionIndex,
139+
reorderFields,
140+
clearDragState,
141+
],
111142
);
112143

113144
const activeField = useMemo(
@@ -135,5 +166,6 @@ export function useFormBuilderDnD() {
135166
activeField,
136167
isDropTarget,
137168
dragWidth,
169+
insertionIndex,
138170
};
139171
}

src/components/builder/hooks/use-form-builder.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,9 @@ export const useFormBuilder = (initialData?: FormBuilderData) => {
110110
/** Reorder fields after drag & drop */
111111
const reorderFields = (activeId: string, overId: string) => {
112112
const oldIndex = fields.findIndex((f) => f.id === activeId);
113-
const newIndex = fields.findIndex((f) => f.id === overId);
114-
if (oldIndex === -1 || newIndex === -1) return;
115-
fieldArray.move(oldIndex, newIndex);
113+
const overIndex = fields.findIndex((f) => f.id === overId);
114+
if (oldIndex === -1 || overIndex === -1) return;
115+
fieldArray.move(oldIndex, overIndex);
116116
};
117117

118118
/** Add option to a select field */

0 commit comments

Comments
 (0)