This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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}