Skip to content

Commit

Permalink
feat: upload component
Browse files Browse the repository at this point in the history
  • Loading branch information
mogusbi-motech committed Apr 14, 2023
1 parent a8b68fb commit e6abaa3
Show file tree
Hide file tree
Showing 9 changed files with 1,184 additions and 67 deletions.
4 changes: 1 addition & 3 deletions packages/project-tailwind/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ export function Button<E extends ElementType = typeof DEFAULT_ELEMENT>({
as={DEFAULT_ELEMENT}
{...{ disabled }}
{...rest}
>
Test
</Box>
/>
);
}
184 changes: 182 additions & 2 deletions packages/project-tailwind/src/components/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
import { ExclamationCircleIcon } from '@heroicons/react/24/solid';
import { ComponentPropsWithRef, ReactNode, useState } from 'react';
import {
ComponentPropsWithRef,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { NumericFormat, PatternFormat } from 'react-number-format';
import { Box } from 'react-polymorphic-box';
import TextareaAutosize, {
TextareaAutosizeProps,
} from 'react-textarea-autosize';
import useMergeRefs from '../utilities/refs';
import {
Sizing,
Themes,
TSizing,
TTheme,
useTailwind,
} from '../utilities/tailwind';
import { Button } from './Button';
import { Tooltip } from './Tooltip';
import { Typography } from './Typography';

// TODO: Radio
// TODO: Checkbox
// TODO: Upload

/** Get message utility type */
type TGetMessage =
Expand Down Expand Up @@ -463,6 +470,7 @@ export function Input({
);
}

/** Textarea component properties */
export interface ITextareaProps extends TextareaAutosizeProps {
/** Validation error message */
errorMessage?: string;
Expand All @@ -483,6 +491,13 @@ export interface ITextareaProps extends TextareaAutosizeProps {
theme?: TTheme;
}

/**
* Multiline, autogrowing textarea form component
*
* @param props - Component props
*
* @returns Textarea component
*/
export function Textarea({
className,
errorMessage,
Expand Down Expand Up @@ -558,3 +573,168 @@ export function Textarea({
/>
);
}

/** Default file upload browse button text */
const BROWSE_TEXT = 'Browse';

/** Upload component properties */
export interface IUploadProps
extends Omit<ComponentPropsWithRef<'input'>, 'type'> {
/** Text to show on the browse button */
buttonText?: string;

/** Validation error message */
errorMessage?: string;

/** Supporting text */
helpText?: string;

/** Input label */
label: string;

/** Input name */
name: string;

/** Component spacing */
spacing?: TSizing;

/** Component theme */
theme?: TTheme;
}

/**
* Form file upload field
*
* @param props - Component props
*
* @returns Upload component
*/
export function Upload({
buttonText = BROWSE_TEXT,
className,
errorMessage,
helpText,
label,
name,
placeholder,
ref = null,
spacing = Sizing.MD,
theme = Themes.SECONDARY,
...rest
}: IUploadProps) {
const [fileName, setFileName] = useState(placeholder);

const innerRef = useRef<HTMLInputElement>(null);

const combinedRef = useMergeRefs(ref, innerRef);

const { getMessage, setTooltip, tooltip } = useInput(name);

const {
hasMessage: hasDescription,
id: describedById,
message: description,
} = getMessage('description', helpText);

const {
hasMessage: hasError,
id: errorMessageId,
message: error,
} = getMessage('error', errorMessage);

const { createStyles } = useTailwind(theme, spacing);

const wrapperStyles = createStyles({
classNames: ['form-input flex p-0'],
theme: {
danger: ['border-red-300'],
primary: ['border-blue-300'],
secondary: ['border-gray-300'],
success: ['border-green-300'],
warning: ['border-yellow-300'],
},
});

const inputStyles = createStyles({
classNames: [
'form-input block w-full h-full sm:text-sm pr-10 flex items-center border-0',
{
'text-gray-500': fileName === placeholder,
},
className,
],
});

const browse = () => {
if (combinedRef?.current) {
combinedRef.current.click();
}
};

useEffect(() => {
if (combinedRef?.current) {
const { current } = combinedRef;

current.onchange = () => {
if (current.files && current.files.length > 0) {
const names = [...current.files]
.map(({ name: uploadedFileName }) => uploadedFileName)
.join(', ');

setFileName(names);
}
};
}
}, [combinedRef]);

return (
<Container
spacing={spacing}
theme={theme}
label={
<Label htmlFor={name} required={rest.required} theme={theme}>
{label}
</Label>
}
input={
<div className={wrapperStyles}>
<Button className="z-10" type="button" theme={theme} onClick={browse}>
{buttonText}
</Button>

<div className="relative block w-full overflow-hidden">
<div className={inputStyles}>
<p className="truncate">{fileName}</p>
</div>

<input
id={name}
className="absolute top-0 left-0 bottom-0 block w-full opacity-0"
type="file"
name={name}
aria-describedby={describedById}
aria-errormessage={tooltip ? errorMessageId : undefined}
aria-invalid={hasError}
ref={combinedRef}
{...rest}
/>
</div>
</div>
}
helpText={
hasDescription && (
<HelpText id={describedById} theme={theme} message={description} />
)
}
validationMessage={
hasError && (
<ValidationMessage
id={errorMessageId}
message={error}
onVisibilityChange={setTooltip}
/>
)
}
/>
);
}
79 changes: 78 additions & 1 deletion packages/project-tailwind/src/components/__tests__/Form.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mockViewport } from 'jsdom-testing-mocks';
import { act } from '@testing-library/react';
import { Input, Textarea } from '../Form';
import { Input, Textarea, Upload } from '../Form';
import { setup, sizing, themes } from '../../utilities/jest';

const types = ['text', 'email', 'password', 'tel'] as const;
Expand Down Expand Up @@ -230,4 +230,81 @@ describe('Form', () => {
);
});
});

describe('Upload', () => {
it('should render the correct output when field is required', () => {
const { asFragment } = setup(
<Upload required label="Test input" name="testInput" />,
);

expect(asFragment()).toMatchSnapshot();
});

it('should render the correct output when custom browse button text is set', () => {
const { asFragment } = setup(
<Upload buttonText="Select file" label="Test input" name="testInput" />,
);

expect(asFragment()).toMatchSnapshot();
});

it('should render the correct output when error message is set', async () => {
const { asFragment, getByTestId, user } = setup(
<Upload
label="Test input"
name="testInput"
errorMessage="This is an error"
/>,
);

await act(async () => {
const parent = getByTestId('tooltip-parent-element');

await user.hover(parent);
});

expect(asFragment()).toMatchSnapshot();
});

it('should render the correct output when a file is selected', async () => {
const file = new File(['hello'], 'hello.png', {
type: 'image/png',
});

const { asFragment, getByLabelText, user } = setup(
<Upload label="Test input" name="testInput" />,
);

await act(async () => {
const input = getByLabelText('Test input');

await user.upload(input, file);
});

expect(asFragment()).toMatchSnapshot();
});

describe.each(themes)('when theme is "$theme"', ({ theme }) => {
it('should render the correct output', () => {
const { asFragment } = setup(
<Upload label="Test input" name="testInput" theme={theme} />,
);

expect(asFragment()).toMatchSnapshot();
});

it('should render the correct output when help text is set', () => {
const { asFragment } = setup(
<Upload
label="Test input"
name="testInput"
helpText="This is a unit test"
theme={theme}
/>,
);

expect(asFragment()).toMatchSnapshot();
});
});
});
});
Loading

0 comments on commit e6abaa3

Please sign in to comment.