FormField.tsx390 lines · main
| 1 | import { format } from 'date-fns' |
| 2 | import { CalendarIcon, ExternalLink } from 'lucide-react' |
| 3 | import { useEffect } from 'react' |
| 4 | import { useFormContext, type Control } from 'react-hook-form' |
| 5 | import ReactMarkdown from 'react-markdown' |
| 6 | import { |
| 7 | Button, |
| 8 | Calendar, |
| 9 | FormControl, |
| 10 | FormInputGroupInput, |
| 11 | Input, |
| 12 | InputGroup, |
| 13 | InputGroupAddon, |
| 14 | Popover, |
| 15 | PopoverContent, |
| 16 | PopoverTrigger, |
| 17 | Select, |
| 18 | SelectContent, |
| 19 | SelectItem, |
| 20 | SelectTrigger, |
| 21 | SelectValue, |
| 22 | Separator, |
| 23 | SheetSection, |
| 24 | Switch, |
| 25 | Textarea, |
| 26 | FormField as UIFormField, |
| 27 | useWatch, |
| 28 | } from 'ui' |
| 29 | import { Input as DataInput } from 'ui-patterns/DataInputs/Input' |
| 30 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 31 | |
| 32 | import type { Enum } from './AuthProvidersForm.types' |
| 33 | import { Markdown } from '@/components/interfaces/Markdown' |
| 34 | import { BASE_PATH } from '@/lib/constants' |
| 35 | |
| 36 | interface FormFieldProps { |
| 37 | projectRef: string | undefined |
| 38 | organizationSlug: string | undefined |
| 39 | name: string |
| 40 | properties: any |
| 41 | control: Control |
| 42 | hasAccess: boolean |
| 43 | disabled?: boolean |
| 44 | readOnly?: boolean |
| 45 | } |
| 46 | |
| 47 | const FormField = ({ |
| 48 | projectRef, |
| 49 | name, |
| 50 | properties, |
| 51 | organizationSlug, |
| 52 | control, |
| 53 | hasAccess, |
| 54 | disabled: disabledProp, |
| 55 | readOnly, |
| 56 | }: FormFieldProps) => { |
| 57 | const { setValue } = useFormContext() |
| 58 | const { description: originalDescription } = properties |
| 59 | let description = originalDescription |
| 60 | |
| 61 | if (originalDescription && projectRef) { |
| 62 | description = originalDescription.replace( |
| 63 | /\(\.\.\/auth\/(.*?)\)/g, |
| 64 | `(/project/${projectRef}/auth/$1)` |
| 65 | ) |
| 66 | } |
| 67 | |
| 68 | const fieldValue = useWatch({ control, name }) |
| 69 | if (!hasAccess) { |
| 70 | const planMessage = organizationSlug |
| 71 | ? `Only available on [Pro plan](/org/${organizationSlug}/billing?panel=subscriptionPlan) and above.` |
| 72 | : '' |
| 73 | description = originalDescription ? `${originalDescription} ${planMessage}` : planMessage |
| 74 | } |
| 75 | const disabled = |
| 76 | disabledProp || (properties.type === 'boolean' ? !hasAccess && !fieldValue : !hasAccess) |
| 77 | |
| 78 | const showValue = useWatch({ |
| 79 | control, |
| 80 | name: properties.show?.key, |
| 81 | disabled: properties.show == null, |
| 82 | }) |
| 83 | |
| 84 | useEffect(() => { |
| 85 | if (properties.show?.key != null && !showValue && fieldValue !== '') { |
| 86 | setValue(name, '', { shouldDirty: true }) |
| 87 | } |
| 88 | }, [fieldValue, name, properties.show?.key, setValue, showValue]) |
| 89 | |
| 90 | if (properties.show) { |
| 91 | if (properties.show.matches) { |
| 92 | if (!properties.show.matches.includes(showValue)) { |
| 93 | return null |
| 94 | } |
| 95 | } else if (!showValue) { |
| 96 | return null |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | switch (properties.type) { |
| 101 | case 'datetime': |
| 102 | return ( |
| 103 | <> |
| 104 | <SheetSection> |
| 105 | <UIFormField |
| 106 | control={control} |
| 107 | name={name} |
| 108 | disabled={disabled || readOnly} |
| 109 | render={({ field }) => ( |
| 110 | <FormItemLayout |
| 111 | layout="horizontal" |
| 112 | label={properties.title} |
| 113 | description={ |
| 114 | description ? ( |
| 115 | <ReactMarkdown unwrapDisallowed disallowedElements={['p']}> |
| 116 | {description} |
| 117 | </ReactMarkdown> |
| 118 | ) : null |
| 119 | } |
| 120 | > |
| 121 | <FormControl> |
| 122 | <Popover> |
| 123 | <PopoverTrigger asChild> |
| 124 | <Button |
| 125 | type="outline" |
| 126 | className="w-full justify-start text-left font-normal px-3 py-4" |
| 127 | icon={<CalendarIcon className="h-4 w-4" />} |
| 128 | size="small" |
| 129 | > |
| 130 | {field.value ? format(new Date(field.value), 'PPP') : 'Pick a date'} |
| 131 | </Button> |
| 132 | </PopoverTrigger> |
| 133 | <PopoverContent className="w-auto p-0" align="start"> |
| 134 | <Calendar |
| 135 | mode="single" |
| 136 | selected={field.value} |
| 137 | onSelect={(date) => { |
| 138 | field.onChange(date?.toISOString()) |
| 139 | }} |
| 140 | initialFocus |
| 141 | /> |
| 142 | </PopoverContent> |
| 143 | </Popover> |
| 144 | </FormControl> |
| 145 | </FormItemLayout> |
| 146 | )} |
| 147 | /> |
| 148 | </SheetSection> |
| 149 | <Separator className="w-full" /> |
| 150 | </> |
| 151 | ) |
| 152 | |
| 153 | case 'string': |
| 154 | return ( |
| 155 | <> |
| 156 | <SheetSection> |
| 157 | <UIFormField |
| 158 | control={control} |
| 159 | name={name} |
| 160 | disabled={disabled} |
| 161 | render={({ field }) => ( |
| 162 | <FormItemLayout |
| 163 | layout="horizontal" |
| 164 | label={properties.title} |
| 165 | description={ |
| 166 | description ? ( |
| 167 | <Markdown content={description} className="text-foreground-lighter" /> |
| 168 | ) : null |
| 169 | } |
| 170 | > |
| 171 | <FormControl className="col-span-6"> |
| 172 | {properties.isSecret ? ( |
| 173 | <DataInput |
| 174 | {...field} |
| 175 | id={name} |
| 176 | size="small" |
| 177 | copy |
| 178 | reveal |
| 179 | readOnly={readOnly} |
| 180 | /> |
| 181 | ) : ( |
| 182 | <Input {...field} id={name} readOnly={readOnly} /> |
| 183 | )} |
| 184 | </FormControl> |
| 185 | </FormItemLayout> |
| 186 | )} |
| 187 | /> |
| 188 | </SheetSection> |
| 189 | <Separator className="w-full" /> |
| 190 | </> |
| 191 | ) |
| 192 | |
| 193 | case 'multiline-string': |
| 194 | return ( |
| 195 | <> |
| 196 | <SheetSection> |
| 197 | <UIFormField |
| 198 | control={control} |
| 199 | name={name} |
| 200 | disabled={disabled} |
| 201 | render={({ field }) => ( |
| 202 | <FormItemLayout |
| 203 | layout="horizontal" |
| 204 | label={properties.title} |
| 205 | description={ |
| 206 | description ? ( |
| 207 | <Markdown content={description} className="text-foreground-lighter" /> |
| 208 | ) : null |
| 209 | } |
| 210 | > |
| 211 | <FormControl className="col-span-6"> |
| 212 | <Textarea |
| 213 | {...field} |
| 214 | id={name} |
| 215 | rows={4} |
| 216 | placeholder="Enter multi-line text" |
| 217 | className="resize-none" |
| 218 | readOnly={readOnly} |
| 219 | /> |
| 220 | </FormControl> |
| 221 | </FormItemLayout> |
| 222 | )} |
| 223 | /> |
| 224 | </SheetSection> |
| 225 | <Separator className="w-full" /> |
| 226 | </> |
| 227 | ) |
| 228 | |
| 229 | case 'number': |
| 230 | return ( |
| 231 | <> |
| 232 | <SheetSection> |
| 233 | <UIFormField |
| 234 | control={control} |
| 235 | name={name} |
| 236 | disabled={disabled} |
| 237 | render={({ field }) => ( |
| 238 | <FormItemLayout |
| 239 | layout="horizontal" |
| 240 | label={properties.title} |
| 241 | description={ |
| 242 | description ? ( |
| 243 | <Markdown content={description} className="text-foreground-lighter" /> |
| 244 | ) : null |
| 245 | } |
| 246 | > |
| 247 | <FormControl className="col-span-6"> |
| 248 | {properties.units ? ( |
| 249 | <InputGroup> |
| 250 | <FormInputGroupInput |
| 251 | {...field} |
| 252 | id={name} |
| 253 | type="number" |
| 254 | onChange={(e) => |
| 255 | field.onChange(e.target.value === '' ? '' : Number(e.target.value)) |
| 256 | } |
| 257 | readOnly={readOnly} |
| 258 | /> |
| 259 | <InputGroupAddon align="inline-end"> |
| 260 | <ReactMarkdown unwrapDisallowed disallowedElements={['p']}> |
| 261 | {properties.units} |
| 262 | </ReactMarkdown> |
| 263 | </InputGroupAddon> |
| 264 | </InputGroup> |
| 265 | ) : ( |
| 266 | <Input |
| 267 | {...field} |
| 268 | id={name} |
| 269 | type="number" |
| 270 | onChange={(e) => |
| 271 | field.onChange(e.target.value === '' ? '' : Number(e.target.value)) |
| 272 | } |
| 273 | readOnly={readOnly} |
| 274 | /> |
| 275 | )} |
| 276 | </FormControl> |
| 277 | </FormItemLayout> |
| 278 | )} |
| 279 | /> |
| 280 | </SheetSection> |
| 281 | <Separator className="w-full" /> |
| 282 | </> |
| 283 | ) |
| 284 | |
| 285 | case 'boolean': |
| 286 | return ( |
| 287 | <> |
| 288 | <SheetSection> |
| 289 | <UIFormField |
| 290 | control={control} |
| 291 | name={name} |
| 292 | disabled={disabled || readOnly} |
| 293 | render={({ field }) => ( |
| 294 | <FormItemLayout |
| 295 | layout="horizontal" |
| 296 | label={properties.title} |
| 297 | description={ |
| 298 | <div className="flex flex-col gap-1"> |
| 299 | {description ? <Markdown content={description} /> : null} |
| 300 | {properties.link && ( |
| 301 | <span> |
| 302 | <Button asChild type="default" size="tiny" icon={<ExternalLink />}> |
| 303 | <a href={properties.link} target="_blank" rel="noreferrer noopener"> |
| 304 | Documentation |
| 305 | </a> |
| 306 | </Button> |
| 307 | </span> |
| 308 | )} |
| 309 | </div> |
| 310 | } |
| 311 | > |
| 312 | <FormControl className="col-span-6"> |
| 313 | <Switch |
| 314 | id={name} |
| 315 | checked={field.value} |
| 316 | onCheckedChange={field.onChange} |
| 317 | size="small" |
| 318 | /> |
| 319 | </FormControl> |
| 320 | </FormItemLayout> |
| 321 | )} |
| 322 | /> |
| 323 | </SheetSection> |
| 324 | <Separator className="w-full" /> |
| 325 | </> |
| 326 | ) |
| 327 | |
| 328 | case 'select': |
| 329 | return ( |
| 330 | <> |
| 331 | <SheetSection> |
| 332 | <UIFormField |
| 333 | control={control} |
| 334 | name={name} |
| 335 | disabled={disabled || readOnly} |
| 336 | render={({ field }) => ( |
| 337 | <FormItemLayout |
| 338 | layout="horizontal" |
| 339 | label={properties.title} |
| 340 | description={ |
| 341 | description ? ( |
| 342 | <div className="form-field-markdown"> |
| 343 | <ReactMarkdown unwrapDisallowed disallowedElements={['p']}> |
| 344 | {description} |
| 345 | </ReactMarkdown> |
| 346 | </div> |
| 347 | ) : null |
| 348 | } |
| 349 | > |
| 350 | <FormControl className="col-span-6"> |
| 351 | <Select |
| 352 | defaultValue={properties.enum[0]?.value} |
| 353 | value={field.value} |
| 354 | onValueChange={field.onChange} |
| 355 | > |
| 356 | <SelectTrigger> |
| 357 | <SelectValue placeholder="Select an option" /> |
| 358 | </SelectTrigger> |
| 359 | <SelectContent> |
| 360 | {properties.enum.map((option: Enum) => ( |
| 361 | <SelectItem key={option.value} value={option.value}> |
| 362 | <span className="flex gap-2 items-center"> |
| 363 | {option.icon ? ( |
| 364 | <img |
| 365 | alt={`${option.label} icon`} |
| 366 | className="h-6 w-6" |
| 367 | src={`${BASE_PATH}/img/icons/${option.icon}`} |
| 368 | /> |
| 369 | ) : null} |
| 370 | {option.label} |
| 371 | </span> |
| 372 | </SelectItem> |
| 373 | ))} |
| 374 | </SelectContent> |
| 375 | </Select> |
| 376 | </FormControl> |
| 377 | </FormItemLayout> |
| 378 | )} |
| 379 | /> |
| 380 | </SheetSection> |
| 381 | <Separator className="w-full" /> |
| 382 | </> |
| 383 | ) |
| 384 | |
| 385 | default: |
| 386 | return null |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | export default FormField |