mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
fix(FE-42): fix validation supplier form and multi select component
This commit is contained in:
@@ -1,28 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import Select, { OptionProps, GroupBase, InputActionMeta } from 'react-select';
|
||||
import {
|
||||
ComponentType,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import Select, {
|
||||
OptionProps,
|
||||
GroupBase,
|
||||
InputActionMeta,
|
||||
MultiValue,
|
||||
SingleValue,
|
||||
} from 'react-select';
|
||||
import CreatableSelect from 'react-select/creatable';
|
||||
import makeAnimated from 'react-select/animated';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export interface OptionType {
|
||||
value: string | number;
|
||||
label: string;
|
||||
className?: string; // for multi select
|
||||
labelClassName?: string; // for multi select
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export type OptionComponent<T = OptionType> = ComponentType<
|
||||
OptionProps<T, boolean, GroupBase<T>>
|
||||
>;
|
||||
|
||||
interface SelectInputProps<T = OptionType> {
|
||||
interface SelectInputBaseProps<T = OptionType> {
|
||||
label?: ReactNode;
|
||||
bottomLabel?: ReactNode;
|
||||
value?: T | T[];
|
||||
onChange?: (val: T | T[] | null) => void;
|
||||
options: T[];
|
||||
optionComponent?: OptionComponent<T>;
|
||||
isDisabled?: boolean;
|
||||
@@ -46,52 +56,78 @@ interface SelectInputProps<T = OptionType> {
|
||||
onInputChange?: (search: string) => void;
|
||||
}
|
||||
|
||||
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
|
||||
createables?: boolean;
|
||||
value?: T | T[] | null;
|
||||
onChange?: (val: T | T[] | null) => void;
|
||||
}
|
||||
|
||||
const animatedComponents = makeAnimated();
|
||||
|
||||
const SelectInput = <T extends OptionType>({
|
||||
label,
|
||||
bottomLabel,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
optionComponent,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
isClearable,
|
||||
isRtl,
|
||||
isSearchable = true,
|
||||
isMulti,
|
||||
placeholder,
|
||||
required,
|
||||
className,
|
||||
isError,
|
||||
errorMessage,
|
||||
isAnimated = true,
|
||||
openMenu,
|
||||
delay = 300,
|
||||
onInputChange,
|
||||
}: SelectInputProps) => {
|
||||
const [internalInputValue, setInternalInputValue] = useState('');
|
||||
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
const {
|
||||
label,
|
||||
bottomLabel,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
optionComponent,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
isClearable,
|
||||
isRtl,
|
||||
isSearchable = true,
|
||||
isMulti,
|
||||
placeholder,
|
||||
required,
|
||||
className,
|
||||
isError,
|
||||
errorMessage,
|
||||
isAnimated = true,
|
||||
openMenu,
|
||||
delay = 300,
|
||||
createables = false,
|
||||
onInputChange,
|
||||
} = props;
|
||||
|
||||
const [debouncedInputValue] = useDebounce(internalInputValue, delay ?? 300);
|
||||
const [internalInputValue, setInternalInputValue] = useState('');
|
||||
const [debouncedInputValue] = useDebounce(internalInputValue, delay);
|
||||
|
||||
const components = useMemo(() => {
|
||||
const base = isAnimated ? animatedComponents : {};
|
||||
|
||||
return {
|
||||
...base,
|
||||
IndicatorSeparator: () => null,
|
||||
};
|
||||
return { ...base, IndicatorSeparator: () => null };
|
||||
}, [isAnimated]);
|
||||
|
||||
const internalInputChangeHandler = (value: string, meta: InputActionMeta) => {
|
||||
if (meta.action === 'input-change') setInternalInputValue(value);
|
||||
const internalInputChangeHandler = (
|
||||
val: string,
|
||||
meta: InputActionMeta
|
||||
) => {
|
||||
if (meta.action === 'input-change') setInternalInputValue(val);
|
||||
if (meta.action === 'menu-close') setInternalInputValue('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onInputChange?.(debouncedInputValue);
|
||||
}, [debouncedInputValue]);
|
||||
}, [onInputChange, debouncedInputValue]);
|
||||
|
||||
const SelectComponent = createables ? CreatableSelect : Select;
|
||||
|
||||
/** 🎯 handleChange tanpa any */
|
||||
const handleChange = (
|
||||
val: MultiValue<T> | SingleValue<T>
|
||||
): void => {
|
||||
if (!val) {
|
||||
onChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMulti) {
|
||||
onChange?.(val as T[]);
|
||||
} else {
|
||||
onChange?.(val as T);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -103,28 +139,23 @@ const SelectInput = <T extends OptionType>({
|
||||
<span
|
||||
className={cn(
|
||||
'w-full text-sm font-normal leading-5',
|
||||
{
|
||||
'text-error': isError,
|
||||
},
|
||||
{ 'text-error': isError },
|
||||
className?.label
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<>
|
||||
{' '}
|
||||
<span className='tooltip tooltip-error' data-tip='required'>
|
||||
<span className='text-error'> *</span>
|
||||
</span>
|
||||
</>
|
||||
<span className="tooltip tooltip-error" data-tip="required">
|
||||
<span className="text-error"> *</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Select
|
||||
instanceId='select'
|
||||
value={value}
|
||||
onChange={(val) => onChange?.(val as T)}
|
||||
<SelectComponent<T, boolean, GroupBase<T>>
|
||||
instanceId="select"
|
||||
value={value ?? (isMulti ? [] : null)}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
menuIsOpen={openMenu}
|
||||
inputValue={internalInputValue}
|
||||
@@ -136,14 +167,13 @@ const SelectInput = <T extends OptionType>({
|
||||
isRtl={isRtl}
|
||||
isSearchable={isSearchable}
|
||||
placeholder={placeholder}
|
||||
className={cn('w-full', className)}
|
||||
className={cn('w-full', className?.select)}
|
||||
classNames={{
|
||||
control: ({ isFocused, isDisabled }) =>
|
||||
cn(
|
||||
'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer!',
|
||||
{
|
||||
'border-red-500! focus-within:border-red-500 focus-within:ring-2 focus-within:ring-red-200':
|
||||
isError,
|
||||
'border-red-500! ring-2 ring-red-200': isError,
|
||||
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
|
||||
'border-gray-300': !isError && !isFocused,
|
||||
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
|
||||
@@ -156,8 +186,6 @@ const SelectInput = <T extends OptionType>({
|
||||
cn({ 'text-gray-900': !isError, 'text-error!': isError }),
|
||||
input: () => cn('text-gray-900'),
|
||||
indicatorsContainer: () => cn('flex items-center gap-1 pr-2'),
|
||||
indicatorSeparator: () => cn('mx-1 h-4 w-px bg-gray-200'),
|
||||
clearIndicator: () => cn('p-1 rounded-md hover:bg-gray-100'),
|
||||
dropdownIndicator: ({ isFocused }) =>
|
||||
cn('p-1 rounded-md hover:bg-gray-100', {
|
||||
'text-gray-900': isFocused,
|
||||
@@ -165,55 +193,41 @@ const SelectInput = <T extends OptionType>({
|
||||
'text-error!': isError,
|
||||
}),
|
||||
menu: () =>
|
||||
cn(
|
||||
'border border-gray-200 rounded-lg bg-white shadow-lg rounded-lg!'
|
||||
),
|
||||
cn('border border-gray-200 rounded-lg bg-white shadow-lg!'),
|
||||
menuList: () => cn('p-2! max-h-60 overflow-auto'),
|
||||
groupHeading: () =>
|
||||
cn('ml-2 mt-2 mb-1 text-xs font-medium text-gray-500'),
|
||||
option: ({ isFocused, isSelected, isDisabled }) =>
|
||||
cn('mt-1 px-3 py-2 rounded-md cursor-pointer! select-none', {
|
||||
'text-gray-300': isDisabled,
|
||||
option: ({ isFocused, isSelected }) =>
|
||||
cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', {
|
||||
'bg-indigo-600 text-white': isFocused,
|
||||
'text-gray-700': !isDisabled && !isFocused,
|
||||
'active:bg-indigo-50': !isDisabled,
|
||||
'bg-blue-500!': isSelected,
|
||||
'text-gray-700': !isFocused && !isSelected,
|
||||
}),
|
||||
noOptionsMessage: () => cn('px-3 py-2 text-gray-500'),
|
||||
loadingMessage: () => cn('px-3 py-2 text-gray-500'),
|
||||
multiValue: ({ getValue, index }) => {
|
||||
const selectedValues = getValue();
|
||||
|
||||
const selectedValues = getValue() as T[];
|
||||
return cn(
|
||||
'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1 rounded-md!',
|
||||
'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1!',
|
||||
selectedValues[index]?.className
|
||||
);
|
||||
},
|
||||
multiValueLabel: ({ getValue, index }) => {
|
||||
const selectedValues = getValue();
|
||||
|
||||
const selectedValues = getValue() as T[];
|
||||
return cn('text-indigo-700', selectedValues[index]?.labelClassName);
|
||||
},
|
||||
multiValueRemove: () =>
|
||||
cn('p-1 rounded-sm! hover:bg-indigo-100 hover:text-indigo-800'),
|
||||
}}
|
||||
components={{
|
||||
...components,
|
||||
...(optionComponent ? { Option: optionComponent } : {}),
|
||||
}}
|
||||
// make the menu float above modals/etc.
|
||||
menuPortalTarget={
|
||||
typeof document !== 'undefined' ? document.body : undefined
|
||||
}
|
||||
styles={{
|
||||
// Tailwind can't set inline z-index on a portal; use styles here:
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
}}
|
||||
/>
|
||||
|
||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
||||
{isError && <p className="w-full text-sm text-error">{errorMessage}</p>}
|
||||
{!isError && bottomLabel && (
|
||||
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
||||
<p className="w-full text-sm opacity-60">{bottomLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,10 @@ import * as Yup from 'yup';
|
||||
|
||||
export const SupplierFormSchema = Yup.object({
|
||||
name: Yup.string().required('Nama wajib diisi!'),
|
||||
alias: Yup.string().required('Alias wajib diisi!'),
|
||||
alias: Yup.string()
|
||||
.matches(/^[A-Za-z0-9]+$/, 'Alias hanya boleh berisi huruf dan angka tanpa spasi atau simbol!')
|
||||
.max(5, 'Alias maksimal 5 karakter!')
|
||||
.required('Alias wajib diisi!'),
|
||||
pic: Yup.string().required('PIC wajib diisi!'),
|
||||
type: Yup.object({
|
||||
value: Yup.string().required(),
|
||||
|
||||
@@ -21,7 +21,6 @@ import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
import TextInput from '@/components/input/TextInput';
|
||||
import TagInput from '@/components/input/TagInput';
|
||||
import TextArea from '@/components/input/TextArea';
|
||||
import { cn } from '@/lib/helper';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
@@ -42,9 +41,7 @@ const SupplierForm = ({
|
||||
// Setup State
|
||||
const [supplierFormErrorMessage, setSupplierFormErrorMessage] = useState('');
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [typeSelectInputValue, setTypeSelectInputValue] = useState('');
|
||||
const [categorySelectInputValue, setCategorySelectInputValue] = useState('');
|
||||
const [hatcheryTagInputValue, setHatcheryTagInputValue] = useState('');
|
||||
const [hatcheryOptionsValues, setHatcheryOptionValues] = useState<OptionType[]>([]);
|
||||
|
||||
// -- Options data mapping
|
||||
const typeOptions = TYPE_OPTIONS;
|
||||
@@ -108,8 +105,6 @@ const SupplierForm = ({
|
||||
};
|
||||
|
||||
// Memo
|
||||
console.log('Memo');
|
||||
console.log(initialValues);
|
||||
const formikInitialValues = useMemo<SupplierFormValues>(() => {
|
||||
return {
|
||||
name: initialValues?.name ?? '',
|
||||
@@ -125,7 +120,7 @@ const SupplierForm = ({
|
||||
account_number: initialValues?.account_number ?? '',
|
||||
due_date: initialValues?.due_date ?? 1,
|
||||
};
|
||||
}, [initialValues]);
|
||||
}, [initialValues, typeOptions, categoryOptions]);
|
||||
|
||||
// Formik
|
||||
const formik = useFormik<SupplierFormValues>({
|
||||
@@ -172,8 +167,22 @@ const SupplierForm = ({
|
||||
// Initialize Formik
|
||||
useEffect(() => {
|
||||
formikSetValues(formikInitialValues);
|
||||
setHatcheryTagInputValue(formikInitialValues.hatchery);
|
||||
}, [formikSetValues, formikInitialValues, hatcheryTagInputValue]);
|
||||
if(formType != 'add'){
|
||||
const hatcheryArrays = formikInitialValues.hatchery.split(',');
|
||||
const hatcheryCreatedOptions = hatcheryArrays.map((item) => ({
|
||||
value: item,
|
||||
label: item,
|
||||
}));
|
||||
setHatcheryOptionValues(hatcheryCreatedOptions);
|
||||
}
|
||||
}, [formikSetValues, formikInitialValues, setHatcheryOptionValues]);
|
||||
useEffect(() => {
|
||||
const commaSeparatedValues = hatcheryOptionsValues.map((item) => item.value).join(',');
|
||||
formikSetValues({
|
||||
...formik.values,
|
||||
hatchery: commaSeparatedValues,
|
||||
})
|
||||
}, [hatcheryOptionsValues, formikSetValues]);
|
||||
|
||||
// Option Handler
|
||||
const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
@@ -260,7 +269,6 @@ const SupplierForm = ({
|
||||
}
|
||||
onChange={typeChangeHandler}
|
||||
options={typeOptions}
|
||||
onInputChange={setTypeSelectInputValue}
|
||||
isError={formik.touched.type && Boolean(formik.errors.type)}
|
||||
errorMessage={formik.errors.type as string}
|
||||
isDisabled={formType === 'detail'}
|
||||
@@ -278,7 +286,6 @@ const SupplierForm = ({
|
||||
}
|
||||
onChange={categoryChangeHandler}
|
||||
options={categoryOptions}
|
||||
onInputChange={setCategorySelectInputValue}
|
||||
isError={
|
||||
formik.touched.category && Boolean(formik.errors.category)
|
||||
}
|
||||
@@ -287,17 +294,25 @@ const SupplierForm = ({
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
/>
|
||||
<TagInput
|
||||
name='hatchery'
|
||||
<SelectInput
|
||||
isMulti
|
||||
createables
|
||||
required
|
||||
placeholder='Pilih Hatchery'
|
||||
label='Hatchery'
|
||||
value={hatcheryTagInputValue}
|
||||
onChange={(value) => formik.setFieldValue('hatchery', value)}
|
||||
isError={
|
||||
formik.touched.hatchery && Boolean(formik.errors.hatchery)
|
||||
}
|
||||
errorMessage={formik.errors.hatchery}
|
||||
readOnly={formType === 'detail'}
|
||||
value={hatcheryOptionsValues}
|
||||
onChange={(val) => {
|
||||
console.log(val); // pastikan val = array of { value, label }
|
||||
setHatcheryOptionValues(val as OptionType[]);
|
||||
}}
|
||||
isError={formik.touched.hatchery && Boolean(formik.errors.hatchery)}
|
||||
errorMessage={formik.errors.hatchery as string}
|
||||
isDisabled={formType === 'detail'}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
options={[]}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
required
|
||||
label='Nomor Telepon'
|
||||
|
||||
Reference in New Issue
Block a user