Skip to content

Commit 80aa2fb

Browse files
committed
Add sortable
1 parent e06f4a0 commit 80aa2fb

File tree

6 files changed

+220
-64
lines changed

6 files changed

+220
-64
lines changed

src/FormBuilder.tsx

Lines changed: 180 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,92 @@
1-
import React, { useEffect } from "react";
1+
import React, { useEffect, useMemo, useState, createContext } from "react";
22
import { Block, BlockDefinition } from "./components/Blocks/Definition";
33
import { useFormBuilderStore } from "./stores/block.store";
44
import { createBlockFromTemplate } from "./utilities/block.utiles";
5-
import { AddMenu } from "./components/AddMenu";
65
import { BLOCK_COMPONENTS } from "./components/BlockRegistry";
76
import { Empty } from "./components/Empty";
87

8+
import {
9+
DndContext,
10+
DragStartEvent,
11+
DragEndEvent,
12+
DragOverlay,
13+
PointerSensor,
14+
useSensor,
15+
useSensors,
16+
closestCenter,
17+
UniqueIdentifier,
18+
} from "@dnd-kit/core";
19+
20+
import {
21+
SortableContext,
22+
useSortable,
23+
verticalListSortingStrategy,
24+
arrayMove,
25+
} from "@dnd-kit/sortable";
26+
27+
import { CSS } from "@dnd-kit/utilities";
28+
import { AddMenu } from "./components/AddMenu";
29+
930
type Props = {
10-
onChange: (blocks: Block[]) => void
31+
onChange: (blocks: Block[]) => void;
1132
json: Block[];
12-
}
33+
};
34+
35+
export const DragHandleContext = createContext<{
36+
attributes?: Record<string, any>;
37+
listeners?: Record<string, any>;
38+
setActivatorNodeRef?: (node: HTMLElement | null) => void;
39+
isDragging?: boolean;
40+
}>({});
41+
42+
const SortableItem: React.FC<{ id: UniqueIdentifier; children: React.ReactNode }> = (
43+
{
44+
id,
45+
children,
46+
}) => {
47+
const {
48+
attributes,
49+
listeners,
50+
setNodeRef,
51+
setActivatorNodeRef,
52+
transform,
53+
transition,
54+
isDragging,
55+
} = useSortable({id});
56+
57+
const style: React.CSSProperties = {
58+
transform: CSS.Transform.toString(transform),
59+
transition,
60+
opacity: isDragging ? 0.6 : 1,
61+
};
62+
63+
return (
64+
<DragHandleContext.Provider
65+
value={{attributes, listeners, setActivatorNodeRef, isDragging}}
66+
>
67+
<div
68+
ref={setNodeRef}
69+
style={style}
70+
data-block-id={String(id)}
71+
>
72+
{children}
73+
</div>
74+
</DragHandleContext.Provider>
75+
);
76+
};
1377

1478
export const FormBuilder = ({onChange, json}: Props) => {
1579
const {blocks, addBlock, setBlocks} = useFormBuilderStore();
80+
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
81+
const [activeSize, setActiveSize] = useState<{ width: number; height: number } | null>(null);
82+
83+
const sensors = useSensors(
84+
useSensor(PointerSensor, {
85+
activationConstraint: {
86+
distance: 2,
87+
},
88+
})
89+
);
1690

1791
const handleAddAt = (afterIndex: number, def: BlockDefinition) => {
1892
const newBlock = createBlockFromTemplate(def);
@@ -24,35 +98,118 @@ export const FormBuilder = ({onChange, json}: Props) => {
2498
}, []);
2599

26100
useEffect(() => {
27-
console.log(blocks);
28101
onChange(blocks);
29102
}, [blocks]);
30103

104+
const ids = useMemo(() => blocks.map((b) => b.id), [blocks]);
105+
106+
const onDragStart = (event: DragStartEvent) => {
107+
setActiveId(event.active.id);
108+
109+
const el = document.querySelector<HTMLElement>(`[data-block-id="${String(event.active.id)}"]`);
110+
if (el) {
111+
const rect = el.getBoundingClientRect();
112+
setActiveSize({width: rect.width, height: rect.height});
113+
} else {
114+
setActiveSize(null);
115+
}
116+
};
117+
118+
const onDragEnd = (event: DragEndEvent) => {
119+
const {active, over} = event;
120+
121+
if (over && active.id !== over.id) {
122+
const oldIndex = blocks.findIndex((b) => b.id === active.id);
123+
const newIndex = blocks.findIndex((b) => b.id === over.id);
124+
125+
if (oldIndex !== -1 && newIndex !== -1) {
126+
const reordered = arrayMove(blocks, oldIndex, newIndex);
127+
setBlocks(reordered);
128+
}
129+
}
130+
131+
setActiveId(null);
132+
setActiveSize(null);
133+
};
134+
135+
const onDragCancel = () => {
136+
setActiveId(null);
137+
setActiveSize(null);
138+
};
139+
31140
return (
32-
<div className="border border-dashed border-gray-200 rounded-lg p-4 space-y-4">
33-
{blocks.length === 0 ? (
34-
<Empty onPick={(def) => handleAddAt(-1, def)}/>
35-
) : (
36-
<>
37-
{
38-
blocks.map((block, idx) => {
141+
<DndContext
142+
sensors={sensors}
143+
collisionDetection={closestCenter}
144+
onDragStart={onDragStart}
145+
onDragEnd={onDragEnd}
146+
onDragCancel={onDragCancel}
147+
>
148+
<div className="border border-dashed border-gray-200 rounded-lg p-4 space-y-4">
149+
{blocks.length === 0 ? (
150+
<Empty onPick={(def) => handleAddAt(-1, def)}/>
151+
) : (
152+
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
153+
{blocks.map((block, idx) => {
39154
const Component = BLOCK_COMPONENTS[block.type];
40-
41155
if (!Component) {
42156
console.error(`Composant non trouvé pour le type: ${block.type}`);
43157
return null;
44158
}
45-
46159
return (
47-
<Component {...block} key={idx}/>
48-
);
49-
})
50-
}
51-
</>
52-
)}
53-
</div>
54-
)
55-
;
160+
<SortableItem key={block.id} id={block.id}>
161+
<div className="group relative">
162+
163+
<div
164+
className="absolute -right-3 -top-3 opacity-0 group-hover:opacity-100 transition-opacity">
165+
<AddMenu
166+
onPick={(def) => handleAddAt(idx, def)}
167+
placeholder="Rechercher un type…"
168+
placement={'right'}
169+
>
170+
<button
171+
type="button"
172+
className="rounded-full border border-gray-300 bg-appolo-700 shadow-sm p-2 hover:bg-appolo-500 cursor-pointer"
173+
title="Ajouter un bloc"
174+
>
175+
<svg width="16" height="16" viewBox="0 0 24 24"
176+
className="text-white">
177+
<path fill="currentColor"
178+
d="M11 11V5h2v6h6v2h-6v6h-2v-6H5v-2z"/>
179+
</svg>
180+
</button>
181+
</AddMenu>
182+
183+
</div>
184+
185+
<Component {...block} />
186+
</div>
187+
</SortableItem>
188+
)
189+
;
190+
})}
191+
</SortableContext>
192+
)}
193+
</div>
194+
195+
<DragOverlay dropAnimation={null}>
196+
{activeId && activeSize ? (
197+
<div
198+
className="bg-appolo-50"
199+
style={{
200+
width: activeSize.width,
201+
height: activeSize.height,
202+
borderRadius: 8,
203+
background: "",
204+
opacity: .6,
205+
boxShadow:
206+
"0 4px 14px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.08)",
207+
}}
208+
/>
209+
) : null}
210+
</DragOverlay>
211+
</DndContext>
212+
);
56213
};
57214

58215
export default FormBuilder;

src/components/AddMenu.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ type AddMenuProps = {
55
onPick: (def: BlockDefinition) => void;
66
trigger?: React.ReactNode;
77
placeholder?: string;
8+
placement?: 'left' | 'right';
89
allowTypes?: Array<BlockDefinition["type"]>;
910
} & PropsWithChildren;
1011

1112
export const AddMenu: React.FC<AddMenuProps> = (
1213
{
1314
onPick,
1415
placeholder = "Rechercher un bloc…",
16+
placement = 'left',
1517
allowTypes,
1618
children
1719
}) => {
@@ -63,7 +65,7 @@ export const AddMenu: React.FC<AddMenuProps> = (
6365
</div>
6466

6567
{open && (
66-
<div className="absolute left-0 z-20 mt-2 w-80 rounded-lg border border-gray-200 bg-white">
68+
<div className={`absolute ${placement}-0 z-20 mt-2 w-80 rounded-lg border border-gray-200 bg-white`}>
6769
<div className="p-2 border-b border-gray-200">
6870
<input
6971
ref={inputRef}

src/components/Blocks/EditableBlock.tsx

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import React, { ReactElement, ReactNode, useState, MouseEvent, useCallback } from "react";
1+
import React, { ReactElement, ReactNode, useState, MouseEvent, useCallback, useContext } from "react";
22
import { EditIcon } from "../Icons/EditIcon";
33
import { TrashIcon } from "../Icons/TrashIcon";
44
import { Tooltip } from "../Tooltip";
55
import { UniqueIdentifier } from "@dnd-kit/core";
66
import { ContextMenu, ContextMenuItem } from "../ContextMenu";
77
import { useBlockOperations } from "../../hooks/useBlockOperations";
8-
import { AddMenu } from "../AddMenu";
98
import { BlockDefinition } from "./Definition";
109
import { createBlockFromTemplate } from "../../utilities/block.utiles";
1110
import { useFormBuilderStore } from "../../stores/block.store";
12-
import { PlusIcon } from "../Icons/PlusIcon";
11+
import { DragHandleContext } from "../../FormBuilder";
12+
import { DotsIcon } from "../Icons/DotsIcon";
1313

1414
interface EditableBlockProps {
1515
id: UniqueIdentifier;
@@ -27,7 +27,6 @@ export const EditableBlock = (
2727
onDelete,
2828
className = "",
2929
}: EditableBlockProps) => {
30-
const {addBlock} = useFormBuilderStore();
3130
const [contextMenuVisible, setContextMenuVisible] = useState(false);
3231
const [contextMenuPosition, setContextMenuPosition] = useState({x: 0, y: 0});
3332

@@ -48,11 +47,6 @@ export const EditableBlock = (
4847
setContextMenuVisible(false);
4948
}, []);
5049

51-
const handleAddAt = (afterIndex: number, def: BlockDefinition) => {
52-
const newBlock = createBlockFromTemplate(def);
53-
addBlock(newBlock, afterIndex + 1);
54-
};
55-
5650
const handleDelete = useCallback(() => {
5751
onDelete?.();
5852
handleRemove();
@@ -61,9 +55,11 @@ export const EditableBlock = (
6155

6256
const items = Array.isArray(editionItems) ? editionItems : [editionItems];
6357

58+
const {attributes, listeners, setActivatorNodeRef} = useContext(DragHandleContext);
59+
6460
return (
6561
<>
66-
<div className={`flex flex-col gap-1.5 ${className}`}>
62+
<div className={`flex gap-2 ${className}`}>
6763
{items.length > 0 && (
6864
<Tooltip content="Paramètres">
6965
<button
@@ -72,7 +68,7 @@ export const EditableBlock = (
7268
onClick={handleOpenContextMenu}
7369
aria-label="Ouvrir les paramètres"
7470
>
75-
<EditIcon size={22}/>
71+
<EditIcon size={20}/>
7672
</button>
7773
</Tooltip>
7874
)}
@@ -84,31 +80,37 @@ export const EditableBlock = (
8480
onClick={handleDelete}
8581
aria-label="Supprimer le bloc"
8682
>
87-
<TrashIcon size={22}/>
83+
<TrashIcon size={20}/>
8884
</button>
8985
</Tooltip>
9086

91-
<Tooltip content="Ajouter un bloc">
92-
<AddMenu onPick={(def) => handleAddAt(id, def)}>
93-
<PlusIcon size={22}/>
94-
</AddMenu>
87+
<Tooltip content="Déplacer le bloc">
88+
<button
89+
type="button"
90+
ref={setActivatorNodeRef}
91+
{...(attributes || {})}
92+
{...(listeners || {})}
93+
className={`cursor-grab active:cursor-grabbing`}
94+
>
95+
<DotsIcon size={20}/>
96+
</button>
9597
</Tooltip>
9698
</div>
9799

98100
{children}
99101

100-
{items.length > 0 && <ContextMenu
101-
visible={contextMenuVisible}
102-
x={contextMenuPosition.x}
103-
y={contextMenuPosition.y}
104-
onClose={handleCloseContextMenu}
105-
>
106-
{items.map((item, index) => (
107-
<ContextMenuItem key={item.key || index}>
108-
{item}
109-
</ContextMenuItem>
110-
))}
111-
</ContextMenu>}
102+
{items.length > 0 && (
103+
<ContextMenu
104+
visible={contextMenuVisible}
105+
x={contextMenuPosition.x}
106+
y={contextMenuPosition.y}
107+
onClose={handleCloseContextMenu}
108+
>
109+
{items.map((item, index) => (
110+
<ContextMenuItem key={item.key || index}>{item}</ContextMenuItem>
111+
))}
112+
</ContextMenu>
113+
)}
112114
</>
113115
);
114116
};

src/components/Blocks/GenericInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const makeInputBlock = (
4949
opts: MakeOpts = {}
5050
) => {
5151
const InputBlock: FC<CommonProps> = (props) => {
52-
const {id, ...restProps} = props;
52+
const {id, index, ...restProps} = props;
5353
const {updateBlock} = useFormBuilderStore();
5454

5555
const [form, setForm] = useState<Record<string, any>>({

src/components/Icons/DotsIcon.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { SvgProps } from "../../types/Icon.type";
2+
3+
export const DotsIcon = ({size = 24}: SvgProps) => (
4+
<svg width={size} height={size} viewBox="0 0 24 24" strokeWidth="1.5" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.5 6C5.77614 6 6 5.77614 6 5.5C6 5.22386 5.77614 5 5.5 5C5.22386 5 5 5.22386 5 5.5C5 5.77614 5.22386 6 5.5 6Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path><path d="M5.5 12.5C5.77614 12.5 6 12.2761 6 12C6 11.7239 5.77614 11.5 5.5 11.5C5.22386 11.5 5 11.7239 5 12C5 12.2761 5.22386 12.5 5.5 12.5Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path><path d="M5.5 19C5.77614 19 6 18.7761 6 18.5C6 18.2239 5.77614 18 5.5 18C5.22386 18 5 18.2239 5 18.5C5 18.7761 5.22386 19 5.5 19Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path><path d="M12 6C12.2761 6 12.5 5.77614 12.5 5.5C12.5 5.22386 12.2761 5 12 5C11.7239 5 11.5 5.22386 11.5 5.5C11.5 5.77614 11.7239 6 12 6Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path><path d="M12 12.5C12.2761 12.5 12.5 12.2761 12.5 12C12.5 11.7239 12.2761 11.5 12 11.5C11.7239 11.5 11.5 11.7239 11.5 12C11.5 12.2761 11.7239 12.5 12 12.5Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path><path d="M12 19C12.2761 19 12.5 18.7761 12.5 18.5C12.5 18.2239 12.2761 18 12 18C11.7239 18 11.5 18.2239 11.5 18.5C11.5 18.7761 11.7239 19 12 19Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path><path d="M18.5 6C18.7761 6 19 5.77614 19 5.5C19 5.22386 18.7761 5 18.5 5C18.2239 5 18 5.22386 18 5.5C18 5.77614 18.2239 6 18.5 6Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path><path d="M18.5 12.5C18.7761 12.5 19 12.2761 19 12C19 11.7239 18.7761 11.5 18.5 11.5C18.2239 11.5 18 11.7239 18 12C18 12.2761 18.2239 12.5 18.5 12.5Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path><path d="M18.5 19C18.7761 19 19 18.7761 19 18.5C19 18.2239 18.7761 18 18.5 18C18.2239 18 18 18.2239 18 18.5C18 18.7761 18.2239 19 18.5 19Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path></svg>
5+
);
6+

0 commit comments

Comments
 (0)