Skip to content

Commit

Permalink
Merge pull request #39 from Myongji-Graduate/form/#16
Browse files Browse the repository at this point in the history
Form/#16
  • Loading branch information
seonghunYang authored Mar 8, 2024
2 parents d18493c + 3e5be8c commit f8e3283
Show file tree
Hide file tree
Showing 21 changed files with 1,059 additions and 39 deletions.
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 { FormState } from '@/app/ui/view/molecule/form/form-root';
import { z } from 'zod';

// message name은 logic 구현할 때 통일할 예정
const SignUpFormSchema = 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 SignUpFormSchema>;

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

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,
},
};
33 changes: 30 additions & 3 deletions app/ui/view/atom/button/button.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { cn } from '@/app/utils/shadcn/utils';
import { cva } from 'class-variance-authority';
import React from 'react';
import LoadingSpinner from '../loading-spinner';

export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'default';

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

export const ButtonVariants = cva(`flex justify-center items-center`, {
Expand All @@ -25,12 +31,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
37 changes: 37 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,37 @@
import TextInput from '../../atom/text-input/text-input';
import { FormContext } from './form.context';
import { useContext } from 'react';
import { useFormStatus } from 'react-dom';

interface FormNumberInputProps {
label: string;
id: string;
placeholder: string;
required?: boolean;
}

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

return (
<div className="group">
<label
htmlFor={id}
className="mb-2 block text-sm font-medium group-has-[:required]:after:pl-1 group-has-[:required]:after:content-['*'] group-has-[:required]:after:text-red-400"
>
{label}
</label>
<TextInput
required={required}
disabled={pending}
error={errors[id] ? true : false}
errorMessages={errors[id]}
type={'number'}
id={id}
name={id}
placeholder={placeholder}
/>
</div>
);
}
37 changes: 37 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,37 @@
import TextInput from '../../atom/text-input/text-input';
import { FormContext } from './form.context';
import { useContext } from 'react';
import { useFormStatus } from 'react-dom';

interface FormPasswordInputProps {
label: string;
id: string;
placeholder: string;
required?: boolean;
}

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

return (
<div className="group">
<label
htmlFor={id}
className="mb-2 block text-sm font-medium group-has-[:required]:after:pl-1 group-has-[:required]:after:content-['*'] group-has-[:required]:after:text-red-400"
>
{label}
</label>
<TextInput
required={required}
disabled={pending}
error={errors[id] ? true : false}
errorMessages={errors[id]}
type={'password'}
id={id}
name={id}
placeholder={placeholder}
/>
</div>
);
}
Loading

0 comments on commit f8e3283

Please sign in to comment.