Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Form/#16 #37

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b642c7b
feat: Add form components
seonghunYang Feb 27, 2024
6312b6b
refactor: Refactor form rendering and add label styling
seonghunYang Feb 27, 2024
da37770
feat: Add number type support to TextInput component
seonghunYang Feb 27, 2024
e26fd75
feat: Add NumberWithPlaceholder story to text-input.stories.tsx
seonghunYang Feb 27, 2024
d3a471a
feat: Add FormSelect component
seonghunYang Feb 27, 2024
6f3d246
feat: Add form number and password inputs
seonghunYang Feb 27, 2024
9cc2281
refactor: Refactor FormRoot component to use useFormState hook
seonghunYang Feb 27, 2024
b639717
feat: Add FormSubmitButton component to FormRoot
seonghunYang Feb 27, 2024
5590017
feat: Add FormContext to form-root.tsx
seonghunYang Feb 27, 2024
67fe26c
chore: Add zod package dependency
seonghunYang Feb 29, 2024
d551ab9
feat: Add text input component to display multiple error messages and…
seonghunYang Feb 29, 2024
a63472e
refactor: Refactor Select component to support multiple error messages
seonghunYang Feb 29, 2024
2e07a21
feat: Add form error handler
seonghunYang Feb 29, 2024
95ea983
feat: Refactor form validation and add password confirmation check
seonghunYang Feb 29, 2024
9268a16
feat: Add validation sign-up-form
seonghunYang Feb 29, 2024
fab3691
[web] chore: Install storybook testing library
seonghunYang Feb 29, 2024
6fd6210
refactor: Refactor SelectRoot component to include a hidden select el…
seonghunYang Feb 29, 2024
b68d49e
feat: Add full play interaction stories
seonghunYang Feb 29, 2024
96840f1
feat: Update form inputs to disable during pending state
seonghunYang Feb 29, 2024
605839a
feat: Add disabled and loading state to Button
seonghunYang Feb 29, 2024
3549a70
feat: Add loading state to form button
seonghunYang Feb 29, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions app/business/auth/user.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use server';

import { State } from '@/app/ui/view/molecule/form/form-root';
import { z } from 'zod';

const SimpleSignUpFormSchema = z
.object({
userId: z
.string()
.min(6, {
message: 'User ID must be at least 6 characters',
})
.max(20, {
message: 'User ID must be at most 20 characters',
}),
password: z.string().regex(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!^%*#?&])[A-Za-z\d@$!^%*#?&]{8,}$/, {
message: 'Password must contain at least 8 characters, one letter, one number and one special character',
}),
confirmPassword: z.string(),
studentNumber: z.string().length(8, { message: 'ν•™λ²ˆμ€ 8자리 μž…λ‹ˆλ‹€' }).startsWith('60', {
message: 'ν•™λ²ˆμ€ 60으둜 μ‹œμž‘ν•©λ‹ˆλ‹€',
}),
english: z.enum(['basic', 'level12', 'level34', 'bypass']),
})
.superRefine(({ confirmPassword, password }, ctx) => {
console.log('refind', confirmPassword, password);
if (confirmPassword !== password) {
ctx.addIssue({
code: 'custom',
message: 'The passwords did not match',
path: ['confirmPassword'],
});
}
});

type User = z.infer<typeof SimpleSignUpFormSchema>;

export async function createUser(prevState: State, formData: FormData): Promise<State> {
const validatedFields = SimpleSignUpFormSchema.safeParse({
userId: formData.get('userId'),
password: formData.get('password'),
confirmPassword: formData.get('confirmPassword'),
studentNumber: formData.get('studentNumber'),
english: formData.get('english'),
});

console.log(validatedFields);
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'error',
};
}

// Call the API to create a user
// but now mock the response
await new Promise((resolve) => {
setTimeout(() => {
resolve('');
}, 3000);
});

return {
errors: {},
message: 'blacnk',
};
}
31 changes: 23 additions & 8 deletions app/ui/view/atom/button/button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,39 +52,54 @@ const meta = {
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const PrimaryButton: StoryObj<typeof Button> = {
export const PrimaryButton: Story = {
args: {
size: 'md',
variant: 'primary',
label: 'μˆ˜κ°•ν˜„ν™© μžμ„Ένžˆλ³΄κΈ°',
},
render: (args) => <Button {...args} />,
};

export const SecondaryButton: StoryObj<typeof Button> = {
export const SecondaryButton: Story = {
args: {
size: 'xs',
variant: 'secondary',
label: 'μ»€μŠ€ν…€ν•˜κΈ°',
},
render: (args) => <Button {...args} />,
};

export const ListActionButton: StoryObj<typeof Button> = {
export const ListActionButton: Story = {
args: {
size: 'default',
variant: 'list',
label: 'μ‚­μ œ',
},
render: (args) => <Button {...args} />,
};

export const TextButton: StoryObj<typeof Button> = {
export const TextButton: Story = {
args: {
size: 'default',
variant: 'text',
label: 'νšŒμ›νƒˆν‡΄ν•˜κΈ°',
},
render: (args) => <Button {...args} />,
};

export const DisabledButton: Story = {
args: {
size: 'md',
variant: 'primary',
label: 'μˆ˜κ°•ν˜„ν™© μžμ„Ένžˆλ³΄κΈ°',
disabled: true,
},
};

export const LoadingButton: Story = {
args: {
size: 'md',
variant: 'primary',
label: 'μˆ˜κ°•ν˜„ν™© μžμ„Ένžˆλ³΄κΈ°',
loading: true,
},
};
29 changes: 27 additions & 2 deletions app/ui/view/atom/button/button.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { cn } from '@/app/utils/shadcn/utils';
import { cva } from 'class-variance-authority';
import React from 'react';
import LoadingSpinner from '../loading-spinner';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
variant?: 'primary' | 'secondary' | 'text' | 'list';
size?: 'xs' | 'sm' | 'md' | 'lg' | 'default';
loading?: boolean;
disabled?: boolean;
}

export const ButtonVariants = cva(`flex justify-center items-center`, {
Expand All @@ -25,12 +29,33 @@ export const ButtonVariants = cva(`flex justify-center items-center`, {
},
});

export const LoadingIconVariants = cva('animate-spin shrink-0', {
variants: {
size: {
default: 'h-6 w-6 mr-1.5 -ml-1',
xs: 'h-6 w-6 mr-1.5 -ml-1',
sm: 'h-5 w-5 mr-1.5 -ml-1',
md: 'h-6 w-6 mr-1.5 -ml-1',
lg: 'h-12 w-12 mr-1.5 -ml-1',
},
},
});

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ label, variant = 'primary', size = 'default', ...props },
{ label, variant = 'primary', size = 'default', loading, disabled, ...props },
ref,
) {
const isDisabled = loading || disabled;

return (
<button className={ButtonVariants({ variant, size })} {...props} ref={ref}>
<button
className={cn(isDisabled && 'opacity-50 cursor-not-allowed', ButtonVariants({ variant, size }))}
{...props}
ref={ref}
>
{loading ? (
<LoadingSpinner className={cn(LoadingIconVariants({ size }))} style={{ transition: `width 150ms` }} />
) : null}
{label}
</button>
);
Expand Down
10 changes: 10 additions & 0 deletions app/ui/view/atom/loading-spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react';

const LoadingSpinner = ({ ...props }) => (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fill="none" d="M0 0h24v24H0z" />
<path d="M18.364 5.636L16.95 7.05A7 7 0 1 0 19 12h2a9 9 0 1 1-2.636-6.364z" />
</svg>
);

export default LoadingSpinner;
42 changes: 41 additions & 1 deletion app/ui/view/atom/text-input/text-input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ export const Password: Story = {
},
};

export const Number: Story = {
args: {
type: 'number',
defaultValue: 123,
},
};

export const NumberWithPlaceholder: Story = {
args: {
type: 'number',
defaultValue: '',
placeholder: 'number',
},
};

export const WithIcon: Story = {
args: {
defaultValue: '',
Expand All @@ -50,7 +65,32 @@ export const WithError: Story = {
args: {
defaultValue: '',
error: true,
errorMessage: 'error message',
errorMessages: ['error message'],
},
};

export const FullTextWithError: Story = {
args: {
defaultValue: 'Full text with errorrrrrrrrrrrrrrrrrrrrrrr',
error: true,
errorMessages: ['error message'],
},
};

export const WithErrors: Story = {
args: {
defaultValue: '',
error: true,
errorMessages: ['error message', 'error message'],
},
};

export const WithIconAndError: Story = {
args: {
defaultValue: '',
error: true,
errorMessages: ['error message'],
icon: MagnifyingGlassIcon,
},
};

Expand Down
29 changes: 21 additions & 8 deletions app/ui/view/atom/text-input/text-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
import React from 'react';
import { twMerge } from 'tailwind-merge';
import { getInputColors } from '@/app/utils/style/color.util';
import { ExclamationCircleIcon } from '@heroicons/react/20/solid';

export interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
type?: 'text' | 'password';
defaultValue?: string;
value?: string;
type?: 'text' | 'password' | 'number';
defaultValue?: string | number;
value?: string | number;
icon?: React.ElementType;
error?: boolean;
errorMessage?: string;
errorMessages?: string[];
disabled?: boolean;
onValueChange?: (value: unknown) => void;
onValueChange?: (value: string) => void;
}

const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
Expand All @@ -21,7 +22,7 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(function Te
value,
icon,
error = false,
errorMessage,
errorMessages,
disabled = false,
placeholder,
className,
Expand Down Expand Up @@ -54,8 +55,9 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(function Te
className={twMerge(
'w-full focus:outline-none focus:ring-0 border-none bg-transparent text-sm rounded-lg transition duration-100 py-2',
'text-black-1',
'[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
Icon ? 'pl-2' : 'pl-3',
error ? 'pr-3' : 'pr-4',
error ? 'pr-9' : 'pr-3',
disabled ? 'text-gray-6 placeholder:text-gray-6' : 'placeholder:text-gray-6',
)}
placeholder={placeholder}
Expand All @@ -64,8 +66,19 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(function Te
onValueChange?.(e.target.value);
}}
/>
{error ? (
<ExclamationCircleIcon
className={twMerge('text-etc-red shrink-0 h-5 w-5 absolute right-0 flex items-center', 'mr-3')}
/>
) : null}
</div>
{error && errorMessage ? <p className={twMerge('text-sm text-etc-red mt-1')}>{errorMessage}</p> : null}
{error && errorMessages
? errorMessages.map((message, index) => (
<p key={index} className={twMerge('text-sm text-etc-red mt-1')}>
{message}
</p>
))
: null}
</>
);
});
Expand Down
32 changes: 32 additions & 0 deletions app/ui/view/molecule/form/form-number-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import TextInput from '../../atom/text-input/text-input';
import { FormContext } from './form.context';
import { useContext } from 'react';
import { useFormStatus } from 'react-dom';

type FormNumberInputProps = {
label: string;
id: string;
placeholder: string;
};

export function FormNumberInput({ label, id, placeholder }: FormNumberInputProps) {
const { errors } = useContext(FormContext);
const { pending } = useFormStatus();

return (
<>
<label htmlFor={id} className="mb-2 block text-sm font-medium">
{label}
</label>
<TextInput
disabled={pending}
error={errors[id] ? true : false}
errorMessages={errors[id]}
type={'number'}
id={id}
name={id}
placeholder={placeholder}
/>
</>
);
}
32 changes: 32 additions & 0 deletions app/ui/view/molecule/form/form-password-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import TextInput from '../../atom/text-input/text-input';
import { FormContext } from './form.context';
import { useContext } from 'react';
import { useFormStatus } from 'react-dom';

type FormPasswordInputProps = {
label: string;
id: string;
placeholder: string;
};

export function FormPasswordInput({ label, id, placeholder }: FormPasswordInputProps) {
const { errors } = useContext(FormContext);
const { pending } = useFormStatus();

return (
<>
<label htmlFor={id} className="mb-2 block text-sm font-medium">
{label}
</label>
<TextInput
disabled={pending}
error={errors[id] ? true : false}
errorMessages={errors[id]}
type={'password'}
id={id}
name={id}
placeholder={placeholder}
/>
</>
);
}
Loading
Loading