refactor(FE-92-87): mengganti select input dengan reuseable component

This commit is contained in:
randy-ar
2025-10-30 21:23:37 +07:00
parent d0d201bf3a
commit 99194eaf80
5 changed files with 320 additions and 205 deletions
+63 -20
View File
@@ -4,10 +4,24 @@ import {
ChangeEventHandler,
FocusEventHandler,
ReactNode,
useState,
} from 'react';
import { cn } from '@/lib/helper';
const formatToISO = (dateStr: string): string | null => {
const parts = dateStr.split('/');
if (parts.length !== 3) return null;
const [day, month, year] = parts;
if (!day || !month || !year) return null;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
};
const formatToLocal = (isoDate: string): string => {
if (!isoDate) return '';
const [year, month, day] = isoDate.split('-');
return `${day}/${month}/${year}`;
};
export interface DateInputProps {
label?: string;
bottomLabel?: string;
@@ -44,9 +58,9 @@ const DateInput = ({
min,
max,
className,
isError,
isValid,
errorMessage,
isError: externalError,
isValid: externalValid,
errorMessage: externalErrorMessage,
startAdornment,
endAdornment,
disabled = false,
@@ -56,6 +70,40 @@ const DateInput = ({
readOnly = false,
isLoading = false,
}: DateInputProps) => {
const [internalError, setInternalError] = useState<string | null>(null);
const minISO = min ? formatToISO(min) ?? undefined : undefined;
const maxISO = max ? formatToISO(max) ?? undefined : undefined;
const valueISO =
value && value.includes('/') ? formatToISO(value) ?? '' : value ?? '';
const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const selectedDate = e.target.value;
const isoMin = minISO;
const isoMax = maxISO;
if (isoMin && selectedDate < isoMin) {
setInternalError(`Tanggal tidak boleh sebelum ${min}`);
} else if (isoMax && selectedDate > isoMax) {
setInternalError(`Tanggal tidak boleh setelah ${max}`);
} else {
setInternalError(null);
}
const event = {
...e,
target: {
...e.target,
value: formatToLocal(selectedDate),
},
};
onChange?.(event as React.ChangeEvent<HTMLInputElement>);
};
const finalIsError = externalError || !!internalError;
const finalErrorMessage = internalError || externalErrorMessage;
return (
<div
className={cn(
@@ -68,9 +116,7 @@ const DateInput = ({
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
{
'text-error': isError,
},
{ 'text-error': finalIsError },
className?.label
)}
>
@@ -90,8 +136,8 @@ const DateInput = ({
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all duration-200 flex items-center',
{
'border-error': isError,
'border-success!': isValid,
'border-error': finalIsError,
'border-success!': externalValid && !finalIsError,
},
className?.inputWrapper
)}
@@ -103,16 +149,13 @@ const DateInput = ({
id={name}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
value={valueISO}
onChange={handleChange}
onBlur={onBlur}
min={min}
max={max}
min={minISO}
max={maxISO}
disabled={disabled}
className={cn(
'grow bg-transparent cursor-pointer',
className?.input
)}
className={cn('grow bg-transparent cursor-pointer', className?.input)}
readOnly={readOnly}
/>
@@ -124,11 +167,11 @@ const DateInput = ({
)}
</div>
{!isError && bottomLabel && (
{!finalIsError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p>
{finalIsError && finalErrorMessage && (
<p className='w-full text-sm text-error'>{finalErrorMessage}</p>
)}
</div>
);
+64 -19
View File
@@ -1,22 +1,23 @@
'use client';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import Select, {
OptionProps,
GroupBase,
InputActionMeta,
MultiValue,
SingleValue,
components as ReactSelectComponents,
ControlProps,
} from 'react-select';
import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce';
import { cn, getByPath } from '@/lib/helper';
import useSWR from 'swr';
import { httpClientFetcher } from '@/services/http/client';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { isResponseSuccess } from '@/lib/api-helper';
export interface OptionType {
value: string | number;
@@ -53,6 +54,7 @@ interface SelectInputBaseProps<T = OptionType> {
openMenu?: boolean;
delay?: number;
onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
}
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
@@ -63,6 +65,33 @@ interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
const animatedComponents = makeAnimated();
const CustomControl = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: ControlProps<Option, IsMulti, Group>
) => {
const { children } = props;
const customProps = props.selectProps as unknown as {
shouldShowAdornment?: boolean;
startAdornment?: ReactNode;
};
const shouldShowAdornment = customProps.shouldShowAdornment ?? false;
const startAdornment = customProps.startAdornment;
return (
<ReactSelectComponents.Control {...props}>
<div className='flex-1 px-4! py-1.5 gap-1 flex items-center'>
{shouldShowAdornment && startAdornment}
{children}
</div>
</ReactSelectComponents.Control>
);
};
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const {
label,
@@ -87,15 +116,24 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
delay = 300,
createables = false,
onInputChange,
startAdornment,
} = props;
const [internalInputValue, setInternalInputValue] = useState('');
const [debouncedInputValue] = useDebounce(internalInputValue, delay);
const shouldShowAdornment = startAdornment && !internalInputValue;
const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {};
return { ...base, IndicatorSeparator: () => null };
}, [isAnimated]);
const customComponents = { ...base, IndicatorSeparator: () => null };
if (startAdornment) {
customComponents.Control = CustomControl;
}
return customComponents;
}, [isAnimated, startAdornment]);
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val);
@@ -148,12 +186,13 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
<SelectComponent<T, boolean, GroupBase<T>>
instanceId='select'
value={value ?? (isMulti ? [] : null)}
onChange={handleChange}
value={value ?? (isMulti ? [] : undefined)}
onChange={onChange ? handleChange : undefined}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
onInputChange={internalInputChangeHandler}
onMenuClose={() => setInternalInputValue('')}
isMulti={isMulti}
isDisabled={isDisabled}
isLoading={isLoading}
@@ -163,17 +202,19 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
placeholder={placeholder}
className={cn('w-full', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn(
'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!',
{
'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,
}
),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'),
...(!startAdornment && {
control: ({ isFocused, isDisabled }) =>
cn(
'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!',
{
'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,
}
),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'),
}),
placeholder: () =>
cn({ 'text-gray-400': !isError, 'text-red-300!': isError }),
singleValue: () =>
@@ -190,7 +231,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
cn('border border-gray-200 rounded! bg-base-100 shadow-lg!'),
menuList: () => cn('p-2! max-h-60 overflow-auto'),
option: ({ isFocused, isSelected }) =>
cn('mt-1 px-3 py-2 rounded cursor-pointer!', {
cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', {
'bg-indigo-600 text-white': isFocused,
'bg-blue-500!': isSelected,
'text-gray-700': !isFocused && !isSelected,
@@ -211,6 +252,10 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
...components,
...(optionComponent ? { Option: optionComponent } : {}),
}}
{...(startAdornment && {
shouldShowAdornment,
startAdornment,
})}
menuPortalTarget={
typeof document !== 'undefined' ? document.body : undefined
}
@@ -371,10 +371,10 @@ const ProjectFlockTable = () => {
const selectableRows = allRows.filter(
(row) => row.original?.approval?.step_number == 1
);
const allSelected = selectableRows.every((row) =>
row.getIsSelected()
) && selectableRows.length != 0;
const allSelected =
selectableRows.every((row) => row.getIsSelected()) &&
selectableRows.length != 0;
const someSelected =
selectableRows.some((row) => row.getIsSelected()) &&
@@ -495,7 +495,7 @@ const ProjectFlockTable = () => {
return (
<>
{currentPageSize > 2 && (
{currentPageSize > 1 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
@@ -505,10 +505,10 @@ const ProjectFlockTable = () => {
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
{currentPageSize <= 1 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>