This repository has no description
semble
/
src
/
webapp
/
features
/
connections
/
components
/
addConnectionDrawer
/
AddConnectionForm.tsx
12 kB
414 lines
1'use client';
2
3import {
4 ActionIcon,
5 Button,
6 Card,
7 Combobox,
8 Divider,
9 Group,
10 Input,
11 ScrollArea,
12 Stack,
13 Text,
14 Textarea,
15 ThemeIcon,
16 Tooltip,
17 useCombobox,
18 VisuallyHidden,
19} from '@mantine/core';
20import { useForm } from '@mantine/form';
21import { notifications } from '@mantine/notifications';
22import { useRef } from 'react';
23import useCreateConnection from '../../lib/mutations/useCreateConnection';
24import {} from 'react-icons/io';
25import { LuChevronsUpDown, LuArrowUpDown } from 'react-icons/lu';
26import { CONNECTION_TYPES } from '../../const/connectionTypes';
27import UrlSearchInput from './UrlSearchInput';
28import SourceCardPreview from './SourceCardPreview';
29import { BsCheck, BsExclamation } from 'react-icons/bs';
30import { BiSolidChevronDown } from 'react-icons/bi';
31
32interface Props {
33 onClose: () => void;
34 /** When provided the source is fixed (connecting from a card). When omitted both URLs are searchable. */
35 sourceUrl?: string;
36 /** When provided the target is prefilled. */
37 targetUrl?: string;
38}
39
40export default function AddConnectionForm(props: Props) {
41 const hasFixedSource = !!props.sourceUrl;
42 const createConnection = useCreateConnection();
43
44 // Track the raw input values so we can auto-confirm valid URLs on submit
45 const rawSourceInput = useRef(props.sourceUrl ?? '');
46 const rawTargetInput = useRef(props.targetUrl ?? '');
47
48 const typeCombobox = useCombobox({
49 onDropdownClose: () => typeCombobox.resetSelectedOption(),
50 });
51
52 const form = useForm({
53 initialValues: {
54 sourceUrl: props.sourceUrl ?? '',
55 targetUrl: props.targetUrl ?? '',
56 connectionType: 'RELATED',
57 note: '',
58 },
59 validateInputOnChange: false,
60 validateInputOnBlur: true,
61 validate: {
62 sourceUrl: (value) => {
63 if (!value || value.trim() === '') {
64 return 'Source URL is required';
65 }
66 try {
67 new URL(value);
68 return null;
69 } catch {
70 return 'Please enter a valid URL';
71 }
72 },
73 targetUrl: (value) => {
74 if (!value || value.trim() === '') {
75 return 'Please enter a URL';
76 }
77 try {
78 new URL(value);
79 return null;
80 } catch {
81 return 'Please enter a valid URL';
82 }
83 },
84 },
85 });
86
87 const MAX_NOTE_LENGTH = 500;
88
89 const handleSwapUrls = () => {
90 const currentSource = form.values.sourceUrl;
91 const currentTarget = form.values.targetUrl;
92 form.setFieldValue('sourceUrl', currentTarget);
93 form.setFieldValue('targetUrl', currentSource);
94 };
95
96 const handleSubmit = (e: React.FormEvent) => {
97 e.preventDefault();
98
99 // Auto-confirm valid URLs that were typed/pasted but not explicitly selected
100 if (!form.values.sourceUrl && rawSourceInput.current) {
101 try {
102 new URL(rawSourceInput.current);
103 form.setFieldValue('sourceUrl', rawSourceInput.current);
104 } catch {
105 // not a valid URL, let validation handle it
106 }
107 }
108 if (!form.values.targetUrl && rawTargetInput.current) {
109 try {
110 new URL(rawTargetInput.current);
111 form.setFieldValue('targetUrl', rawTargetInput.current);
112 } catch {
113 // not a valid URL, let validation handle it
114 }
115 }
116
117 const validation = form.validate();
118 if (validation.hasErrors) {
119 return;
120 }
121
122 const values = form.getValues();
123
124 if (values.sourceUrl === values.targetUrl) {
125 notifications.show({
126 id: 'same-url-error',
127 title: 'A link cannot be connected to itself',
128 message: 'Please choose a different link to connect',
129 });
130 return;
131 }
132
133 createConnection.mutate(
134 {
135 sourceUrl: values.sourceUrl,
136 targetUrl: values.targetUrl,
137 connectionType: values.connectionType
138 ? (values.connectionType as any)
139 : undefined,
140 note: values.note || undefined,
141 },
142 {
143 onSuccess: () => {
144 props.onClose();
145 notifications.show({
146 color: 'green',
147 title: 'Success!',
148 message: 'Connection created',
149 position: 'top-center',
150 loading: false,
151 autoClose: 2000,
152 icon: <BsCheck />,
153 });
154 },
155 onError: () => {
156 notifications.show({
157 message: 'Could not create connection.',
158 color: 'red',
159 autoClose: 5000,
160 withCloseButton: true,
161 position: 'top-center',
162 icon: <BsExclamation />,
163 });
164 },
165 onSettled: () => {
166 form.reset();
167 },
168 },
169 );
170 };
171
172 // ---- Source slot ----
173 const sourceSlot = hasFixedSource ? (
174 <Stack gap={0}>
175 <SourceCardPreview sourceUrl={form.values.sourceUrl} />
176 <VisuallyHidden>
177 <Input.Label htmlFor="sourceUrl">From</Input.Label>
178 </VisuallyHidden>
179 </Stack>
180 ) : (
181 <Stack gap={0}>
182 <UrlSearchInput
183 id="sourceUrl"
184 label="From"
185 placeholder="Search or paste a link"
186 value={form.values.sourceUrl}
187 error={form.errors.sourceUrl}
188 onUrlSelect={(url) => form.setFieldValue('sourceUrl', url)}
189 onInputChange={(raw) => {
190 rawSourceInput.current = raw;
191 }}
192 />
193 {form.errors.sourceUrl && (
194 <Text size="sm" c="red" mt="xs">
195 {form.errors.sourceUrl}
196 </Text>
197 )}
198 </Stack>
199 );
200
201 // ---- Target slot ----
202 const targetSlot = (
203 <Stack gap={0}>
204 <UrlSearchInput
205 id="targetUrl"
206 label="To"
207 placeholder="Search or paste a link"
208 value={form.values.targetUrl}
209 error={form.errors.targetUrl}
210 onUrlSelect={(url) => form.setFieldValue('targetUrl', url)}
211 onInputChange={(raw) => {
212 rawTargetInput.current = raw;
213 }}
214 />
215 {form.errors.targetUrl && (
216 <Text size="sm" c="red" mt="xs">
217 {form.errors.targetUrl}
218 </Text>
219 )}
220 </Stack>
221 );
222
223 // ---- Connection type selector ----
224 const connectionTypeSelector = (
225 <Card
226 radius="xl"
227 bg="var(--mantine-color-default-hover)"
228 p="xs"
229 w="fit-content"
230 mx="auto"
231 >
232 <Group gap={'xs'} align="center" justify="center">
233 <Combobox
234 shadow="sm"
235 radius="md"
236 store={typeCombobox}
237 position="bottom"
238 width={320}
239 onOptionSubmit={(value) => {
240 form.setFieldValue('connectionType', value);
241 typeCombobox.closeDropdown();
242 }}
243 >
244 <Combobox.Target>
245 <Button
246 color="green"
247 size="sm"
248 onClick={() => typeCombobox.toggleDropdown()}
249 leftSection={
250 form.values.connectionType
251 ? (() => {
252 const selectedType = CONNECTION_TYPES.find(
253 (t) => t.value === form.values.connectionType,
254 );
255 const Icon = selectedType?.icon;
256 return Icon ? <Icon size={16} /> : null;
257 })()
258 : null
259 }
260 rightSection={<LuChevronsUpDown />}
261 >
262 {form.values.connectionType
263 ? CONNECTION_TYPES.find(
264 (t) => t.value === form.values.connectionType,
265 )?.label
266 : 'Select a relation'}
267 </Button>
268 </Combobox.Target>
269 <Combobox.Dropdown>
270 <Combobox.Options>
271 <ScrollArea.Autosize type="scroll" mah={300}>
272 {CONNECTION_TYPES.map((type) => {
273 const Icon = type.icon;
274 const isSelected = form.values.connectionType === type.value;
275 return (
276 <Combobox.Option
277 key={type.value}
278 value={type.value}
279 p={5}
280 bg={
281 isSelected
282 ? 'var(--mantine-color-green-light)'
283 : undefined
284 }
285 >
286 <Group gap="sm" wrap="nowrap">
287 {Icon && <Icon size={20} color="green" />}
288 <Stack gap={0} style={{ flex: 1 }}>
289 <Text
290 size="sm"
291 c={'bright'}
292 fw={isSelected ? 600 : 500}
293 >
294 {type.label}
295 </Text>
296 <Text size="xs" c="dimmed">
297 {type.description}
298 </Text>
299 </Stack>
300 </Group>
301 </Combobox.Option>
302 );
303 })}
304 </ScrollArea.Autosize>
305 </Combobox.Options>
306 </Combobox.Dropdown>
307 </Combobox>
308
309 <Tooltip
310 label={
311 form.values.targetUrl
312 ? 'Swap'
313 : 'You need to add a link before swapping'
314 }
315 position="top"
316 >
317 <ActionIcon
318 variant="light"
319 size={'lg'}
320 color={'blue'}
321 radius={'xl'}
322 onClick={handleSwapUrls}
323 disabled={!form.values.targetUrl}
324 title={
325 form.values.targetUrl
326 ? 'Swap'
327 : 'You need to add a link before swapping'
328 }
329 >
330 <LuArrowUpDown size={16} />
331 </ActionIcon>
332 </Tooltip>
333 </Group>
334 </Card>
335 );
336
337 return (
338 <form
339 onSubmit={handleSubmit}
340 style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
341 >
342 <Stack gap={'lg'} style={{ flex: 1 }}>
343 {/* Source → Type selector → Target (single layout for both modes) */}
344 <Stack gap={0}>
345 {sourceSlot}
346
347 <Divider orientation="vertical" size={'md'} h={20} mx={'auto'} />
348
349 {connectionTypeSelector}
350
351 <Stack align="center" gap={0}>
352 <Divider orientation="vertical" size={'md'} h={20} mx={'auto'} />
353
354 <ThemeIcon
355 size={'xs'}
356 color={'var(--mantine-color-disabled-border)'}
357 c={'gray'}
358 radius={'xl'}
359 >
360 <BiSolidChevronDown size={12} />
361 </ThemeIcon>
362 </Stack>
363
364 {targetSlot}
365 </Stack>
366
367 <Stack gap={0}>
368 <Group justify="space-between">
369 <Input.Label size="md" htmlFor="note">
370 Note
371 </Input.Label>
372 <Text c={'gray'} aria-hidden>
373 {form.getValues().note.length} / {MAX_NOTE_LENGTH}
374 </Text>
375 </Group>
376
377 <Textarea
378 id="note"
379 placeholder={
380 CONNECTION_TYPES.find(
381 (t) => t.value === form.values.connectionType,
382 )?.notePlaceholder ??
383 'Explain the relationship between these resources...'
384 }
385 variant="filled"
386 size="md"
387 rows={3}
388 maxLength={MAX_NOTE_LENGTH}
389 aria-describedby="note-char-remaining"
390 key={form.key('note')}
391 {...form.getInputProps('note')}
392 />
393 <VisuallyHidden id="note-char-remaining" aria-live="polite">
394 {`${MAX_NOTE_LENGTH - form.getValues().note.length} characters remaining`}
395 </VisuallyHidden>
396 </Stack>
397
398 <Group justify="space-between" gap={'xs'} grow mt="auto" mb="md">
399 <Button
400 variant="light"
401 size="md"
402 color={'gray'}
403 onClick={props.onClose}
404 >
405 Cancel
406 </Button>
407 <Button type="submit" size="md" loading={createConnection.isPending}>
408 Create
409 </Button>
410 </Group>
411 </Stack>
412 </form>
413 );
414}