Skip to content

Commit a7bb10e

Browse files
feat: Profile Header & User Form (#4987)
Co-authored-by: Lee Hansel Solevilla <[email protected]> Co-authored-by: Lee Hansel Solevilla <[email protected]>
1 parent b60d697 commit a7bb10e

32 files changed

+1493
-663
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Popover, PopoverAnchor } from '@radix-ui/react-popover';
2+
import React, { useRef, useState } from 'react';
3+
import classNames from 'classnames';
4+
import type { TextFieldProps } from './TextField';
5+
import { TextField } from './TextField';
6+
import { PopoverContent } from '../popover/Popover';
7+
import { Typography } from '../typography/Typography';
8+
import { GenericLoaderSpinner } from '../utilities/loaders';
9+
import { IconSize } from '../Icon';
10+
11+
interface AutocompleteProps
12+
extends Omit<TextFieldProps, 'inputId' | 'onChange' | 'onSelect'> {
13+
name: string;
14+
onChange: (value: string) => void;
15+
onSelect: (value: string) => void;
16+
selectedValue?: string;
17+
options: Array<{ value: string; label: string }>;
18+
isLoading?: boolean;
19+
}
20+
21+
const Autocomplete = ({
22+
name,
23+
isLoading,
24+
options,
25+
onChange,
26+
onSelect,
27+
selectedValue,
28+
defaultValue,
29+
...restProps
30+
}: AutocompleteProps) => {
31+
const [input, setInput] = useState(defaultValue || '');
32+
const [isOpen, setIsOpen] = useState(false);
33+
const inputRef = useRef<HTMLInputElement | null>(null);
34+
const handleChange = (val: string) => {
35+
setInput(val);
36+
onChange(val);
37+
};
38+
const handleSelect = (opt: { value: string; label: string }) => {
39+
setInput(opt.label);
40+
onSelect(opt.value);
41+
setIsOpen(false);
42+
inputRef.current?.focus();
43+
};
44+
const handleBlur = () => {
45+
setIsOpen(false);
46+
setInput(options.find((opt) => opt.value === selectedValue)?.label || '');
47+
};
48+
49+
return (
50+
<Popover open={isOpen}>
51+
<PopoverAnchor asChild>
52+
<TextField
53+
inputRef={(ref) => {
54+
inputRef.current = ref;
55+
}}
56+
inputId={name}
57+
{...restProps}
58+
onChange={(e) => {
59+
handleChange(e.target.value);
60+
setIsOpen(true);
61+
}}
62+
onFocus={() => setIsOpen(true)}
63+
onBlur={handleBlur}
64+
value={input}
65+
autoComplete="off"
66+
/>
67+
</PopoverAnchor>
68+
<PopoverContent
69+
className="rounded-16 border border-border-subtlest-tertiary bg-background-popover p-4 data-[side=bottom]:mt-1 data-[side=top]:mb-1"
70+
side="bottom"
71+
align="start"
72+
avoidCollisions
73+
sameWidthAsAnchor
74+
onOpenAutoFocus={(e) => e.preventDefault()} // keep focus in input
75+
onCloseAutoFocus={(e) => e.preventDefault()} // avoid refocus jumps
76+
>
77+
{!isLoading ? (
78+
<div className="flex w-full flex-col">
79+
{options?.length > 0 ? (
80+
options.map((opt) => (
81+
<button
82+
type="button"
83+
className={classNames(
84+
'text-left',
85+
selectedValue === opt.value && 'font-bold',
86+
)}
87+
key={opt.value}
88+
onMouseDown={(e) => {
89+
e.preventDefault();
90+
handleSelect(opt);
91+
}}
92+
>
93+
{opt.label}
94+
</button>
95+
))
96+
) : (
97+
<Typography>No results</Typography>
98+
)}
99+
</div>
100+
) : (
101+
<GenericLoaderSpinner className="mx-auto" size={IconSize.Small} />
102+
)}
103+
</PopoverContent>
104+
</Popover>
105+
);
106+
};
107+
108+
export default Autocomplete;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Controller, useFormContext } from 'react-hook-form';
2+
import React from 'react';
3+
import type { TextFieldProps } from './TextField';
4+
import { TextField } from './TextField';
5+
6+
type ControlledTextFieldProps = Pick<
7+
TextFieldProps,
8+
'name' | 'label' | 'leftIcon' | 'placeholder' | 'hint'
9+
>;
10+
11+
const ControlledTextField = ({
12+
name,
13+
hint,
14+
...restProps
15+
}: ControlledTextFieldProps) => {
16+
const { control } = useFormContext();
17+
18+
return (
19+
<Controller
20+
control={control}
21+
name={name}
22+
render={({ field, fieldState }) => (
23+
<TextField
24+
inputId={field.name}
25+
{...restProps}
26+
{...field}
27+
valid={!fieldState.error}
28+
hint={fieldState.error ? fieldState.error.message : hint}
29+
/>
30+
)}
31+
/>
32+
);
33+
};
34+
export default ControlledTextField;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from 'react';
2+
import { Controller, useFormContext } from 'react-hook-form';
3+
import Textarea from './Textarea';
4+
import type { BaseFieldProps } from './BaseFieldContainer';
5+
6+
const ControlledTextarea = ({
7+
name,
8+
...restProps
9+
}: Pick<BaseFieldProps<HTMLTextAreaElement>, 'name' | 'label'>) => {
10+
const { control } = useFormContext();
11+
12+
return (
13+
<Controller
14+
control={control}
15+
name={name}
16+
render={({ field, fieldState }) => (
17+
<Textarea
18+
inputId={field.name}
19+
{...restProps}
20+
{...field}
21+
valid={!fieldState.error}
22+
/>
23+
)}
24+
/>
25+
);
26+
};
27+
28+
export default ControlledTextarea;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from 'react';
2+
import { Controller, useFormContext } from 'react-hook-form';
3+
import MarkdownInput from '.';
4+
5+
const ControlledMarkdownInput = ({
6+
name,
7+
rules,
8+
...props
9+
}: {
10+
name: string;
11+
rules?: Record<string, unknown>;
12+
} & React.ComponentProps<typeof MarkdownInput>) => {
13+
const { control, setValue } = useFormContext();
14+
return (
15+
<Controller
16+
name={name}
17+
control={control}
18+
rules={rules}
19+
render={({ field }) => (
20+
<MarkdownInput
21+
{...props}
22+
{...field}
23+
initialContent={field.value}
24+
onValueUpdate={(value) => setValue(name, value)}
25+
/>
26+
)}
27+
/>
28+
);
29+
};
30+
31+
export default ControlledMarkdownInput;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Controller, useFormContext } from 'react-hook-form';
2+
import React from 'react';
3+
import { ArrowIcon } from '../icons';
4+
import {
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuOptions,
8+
DropdownMenuTrigger,
9+
} from '../dropdown/DropdownMenu';
10+
import type { ButtonProps, IconType } from '../buttons/Button';
11+
import { Button, ButtonVariant } from '../buttons/Button';
12+
import type { MenuItemProps } from '../dropdown/common';
13+
14+
type SelectProps = {
15+
name: string;
16+
options: { value: string; label: string }[];
17+
placeholder?: string;
18+
icon?: IconType;
19+
buttonProps?: ButtonProps<'button'>;
20+
};
21+
22+
const Select = ({
23+
name,
24+
options,
25+
placeholder,
26+
icon,
27+
buttonProps,
28+
}: SelectProps) => {
29+
const { control, setValue } = useFormContext();
30+
31+
const menuItems: MenuItemProps[] = options.map((opt) => {
32+
return {
33+
label: opt.label,
34+
action: () => setValue(name, opt.value),
35+
};
36+
});
37+
38+
return (
39+
<Controller
40+
name={name}
41+
control={control}
42+
render={({ field }) => (
43+
<>
44+
<input type="hidden" {...field} />
45+
<DropdownMenu>
46+
<DropdownMenuTrigger className="w-full" asChild>
47+
<Button
48+
icon={icon}
49+
variant={ButtonVariant.Float}
50+
{...buttonProps}
51+
>
52+
{options.find((opt) => opt.value === field.value)?.label ||
53+
placeholder}
54+
<ArrowIcon className="ml-auto rotate-180" secondary />
55+
</Button>
56+
</DropdownMenuTrigger>
57+
<DropdownMenuContent
58+
align="start"
59+
sideOffset={10}
60+
className="flex w-[var(--radix-popper-anchor-width)] flex-col gap-1 overflow-y-auto overflow-x-hidden !p-0"
61+
>
62+
<DropdownMenuOptions options={menuItems} />
63+
</DropdownMenuContent>
64+
</DropdownMenu>
65+
</>
66+
)}
67+
/>
68+
);
69+
};
70+
71+
export default Select;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from 'react';
2+
import { CameraIcon, ClearIcon } from '../icons';
3+
import { IconSize } from '../Icon';
4+
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
5+
import { fallbackImages } from '../../lib/config';
6+
import { useControlledImageUpload } from '../../hooks/useControlledImageUpload';
7+
8+
interface ControlledAvatarUploadProps {
9+
name: string;
10+
currentImageName: string;
11+
fileSizeLimitMB?: number;
12+
}
13+
14+
const ControlledAvatarUpload = ({
15+
name,
16+
currentImageName,
17+
fileSizeLimitMB = 1,
18+
}: ControlledAvatarUploadProps) => {
19+
const {
20+
displayImage,
21+
inputRef,
22+
onFileChange,
23+
onDragOver,
24+
onDrop,
25+
onUploadClick,
26+
onRemove,
27+
acceptedTypes,
28+
} = useControlledImageUpload({
29+
name,
30+
fileSizeLimitMB,
31+
currentImageName,
32+
fallbackImage: fallbackImages.avatar,
33+
});
34+
35+
return (
36+
<div
37+
className="group relative size-[120px]"
38+
onDragOver={onDragOver}
39+
onDrop={onDrop}
40+
>
41+
<div className="relative size-full overflow-hidden rounded-26">
42+
<img
43+
src={displayImage}
44+
alt="Profile avatar"
45+
className="size-full object-cover"
46+
data-testid="image_avatar_file"
47+
/>
48+
</div>
49+
<div className="cursor:pointer absolute top-0 flex h-full w-full items-center justify-center gap-2 rounded-26">
50+
<Button
51+
type="button"
52+
className="bg-shadow-shadow3"
53+
variant={ButtonVariant.Float}
54+
size={ButtonSize.Small}
55+
icon={<CameraIcon size={IconSize.Medium} />}
56+
onClick={onUploadClick}
57+
/>
58+
<Button
59+
type="button"
60+
className="bg-shadow-shadow3"
61+
variant={ButtonVariant.Float}
62+
size={ButtonSize.Small}
63+
icon={<ClearIcon size={IconSize.Medium} />}
64+
onClick={onRemove}
65+
/>
66+
</div>
67+
<input
68+
ref={inputRef}
69+
type="file"
70+
accept={acceptedTypes}
71+
className="hidden"
72+
onChange={onFileChange}
73+
/>
74+
</div>
75+
);
76+
};
77+
78+
export default ControlledAvatarUpload;

0 commit comments

Comments
 (0)