mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
498602a2c9
variables
610 lines
19 KiB
TypeScript
610 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
|
|
import Select, {
|
|
OptionProps,
|
|
GroupBase,
|
|
InputActionMeta,
|
|
MultiValue,
|
|
SingleValue,
|
|
components as ReactSelectComponents,
|
|
ControlProps,
|
|
MenuListProps,
|
|
} 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 useSWRInfinite from 'swr/infinite';
|
|
import { httpClientFetcher } from '@/services/http/client';
|
|
import {
|
|
BaseApiResponse,
|
|
ErrorApiResponse,
|
|
SuccessApiResponse,
|
|
} from '@/types/api/api-general';
|
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
|
|
|
export interface OptionType {
|
|
value: string | number;
|
|
label: string;
|
|
className?: string;
|
|
labelClassName?: string;
|
|
}
|
|
|
|
export type OptionComponent<T = OptionType> = ComponentType<
|
|
OptionProps<T, boolean, GroupBase<T>>
|
|
>;
|
|
|
|
interface SelectInputBaseProps<T = OptionType> {
|
|
label?: ReactNode;
|
|
bottomLabel?: ReactNode;
|
|
options: T[];
|
|
optionComponent?: OptionComponent<T>;
|
|
components?: Partial<typeof ReactSelectComponents>;
|
|
isDisabled?: boolean;
|
|
readOnly?: boolean;
|
|
isLoading?: boolean;
|
|
isClearable?: boolean;
|
|
isRtl?: boolean;
|
|
isSearchable?: boolean;
|
|
isMulti?: boolean;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
className?: {
|
|
wrapper?: string;
|
|
label?: string;
|
|
select?: string;
|
|
inputPrefix?: string;
|
|
inputSuffix?: string;
|
|
inputPrefixSuffixWrapper?: string;
|
|
};
|
|
isError?: boolean;
|
|
errorMessage?: string;
|
|
isAnimated?: boolean;
|
|
openMenu?: boolean;
|
|
delay?: number;
|
|
onInputChange?: (search: string) => void;
|
|
startAdornment?: ReactNode;
|
|
inputPrefix?: ReactNode;
|
|
inputSuffix?: ReactNode;
|
|
menuPortalTarget?: HTMLElement | null;
|
|
closeMenuOnSelect?: boolean;
|
|
hideSelectedOptions?: boolean;
|
|
onMenuScrollToBottom?: ((event: WheelEvent | TouchEvent) => void) | undefined;
|
|
}
|
|
|
|
export interface SelectInputProps<T = OptionType>
|
|
extends SelectInputBaseProps<T> {
|
|
createables?: boolean;
|
|
value?: T | T[] | null;
|
|
onChange?: (val: T | T[] | null) => void;
|
|
}
|
|
|
|
const animatedComponents = makeAnimated();
|
|
|
|
const CustomControl = <
|
|
Option,
|
|
IsMulti extends boolean,
|
|
Group extends GroupBase<Option>,
|
|
>(
|
|
props: ControlProps<Option, IsMulti, Group>
|
|
) => {
|
|
const { children, innerProps } = 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 pl-3 gap-1 flex items-center' {...innerProps}>
|
|
{shouldShowAdornment && startAdornment}
|
|
{children}
|
|
</div>
|
|
</ReactSelectComponents.Control>
|
|
);
|
|
};
|
|
|
|
const CustomMenuList = <
|
|
Option,
|
|
IsMulti extends boolean,
|
|
Group extends GroupBase<Option>,
|
|
>(
|
|
props: MenuListProps<Option, IsMulti, Group>
|
|
) => {
|
|
const { children, selectProps, options } = props;
|
|
const { isLoading } = selectProps;
|
|
|
|
return (
|
|
<ReactSelectComponents.MenuList {...props}>
|
|
{children}
|
|
|
|
{options.length > 0 && isLoading && (
|
|
<div className='px-3 py-2 rounded-md text-center text-gray-400'>
|
|
<span className='loading loading-spinner loading-md' />
|
|
</div>
|
|
)}
|
|
</ReactSelectComponents.MenuList>
|
|
);
|
|
};
|
|
|
|
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
|
const {
|
|
label,
|
|
bottomLabel,
|
|
value,
|
|
onChange,
|
|
options,
|
|
optionComponent,
|
|
components: customComponents,
|
|
isDisabled,
|
|
isLoading,
|
|
isClearable,
|
|
isRtl,
|
|
isSearchable = true,
|
|
isMulti,
|
|
placeholder,
|
|
required,
|
|
className,
|
|
isError,
|
|
errorMessage,
|
|
isAnimated = true,
|
|
openMenu,
|
|
delay = 300,
|
|
createables = false,
|
|
onInputChange,
|
|
startAdornment,
|
|
inputPrefix,
|
|
inputSuffix,
|
|
menuPortalTarget,
|
|
closeMenuOnSelect,
|
|
hideSelectedOptions,
|
|
onMenuScrollToBottom,
|
|
readOnly,
|
|
} = props;
|
|
|
|
const [internalInputValue, setInternalInputValue] = useState('');
|
|
const [debouncedInputValue] = useDebounce(internalInputValue, delay);
|
|
|
|
const shouldShowAdornment = startAdornment && !internalInputValue;
|
|
|
|
const components = useMemo(() => {
|
|
const base = isAnimated ? animatedComponents : {};
|
|
const mergedComponents = { ...base, IndicatorSeparator: () => null };
|
|
|
|
if (startAdornment) {
|
|
mergedComponents.Control = CustomControl;
|
|
}
|
|
|
|
if (customComponents) {
|
|
Object.assign(mergedComponents, customComponents);
|
|
}
|
|
|
|
return mergedComponents;
|
|
}, [isAnimated, startAdornment, customComponents]);
|
|
|
|
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
|
|
if (meta.action === 'input-change') setInternalInputValue(val);
|
|
if (meta.action === 'menu-close') setInternalInputValue('');
|
|
};
|
|
|
|
useEffect(() => {
|
|
onInputChange?.(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('w-full flex flex-col text-start', className?.wrapper)}>
|
|
{label && (
|
|
<span
|
|
className={cn(
|
|
'w-full py-2 text-xs font-semibold leading-5',
|
|
{ 'text-error': isError },
|
|
className?.label
|
|
)}
|
|
>
|
|
{label}
|
|
{required && (
|
|
<>
|
|
{' '}
|
|
<span className='tooltip tooltip-error' data-tip='required'>
|
|
<span className='text-error'>*</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
)}
|
|
|
|
{inputPrefix || inputSuffix ? (
|
|
<div
|
|
className={cn(
|
|
'relative flex text-sm',
|
|
className?.inputPrefixSuffixWrapper
|
|
)}
|
|
>
|
|
{inputPrefix && (
|
|
<div
|
|
className={cn(
|
|
'inline-flex items-center px-3 border border-r-0 border-base-content/10 rounded-l-lg transition-all duration-200',
|
|
{
|
|
'bg-base-100 border-base-content/10': !isDisabled,
|
|
'bg-base-200 border-base-content/10': isDisabled,
|
|
'border-error': isError,
|
|
},
|
|
className?.inputPrefix
|
|
)}
|
|
>
|
|
{inputPrefix}
|
|
</div>
|
|
)}
|
|
|
|
<SelectComponent<T, boolean, GroupBase<T>>
|
|
instanceId='select'
|
|
value={value ?? (isMulti ? [] : null)}
|
|
onChange={onChange ? handleChange : undefined}
|
|
options={options}
|
|
menuIsOpen={openMenu}
|
|
inputValue={internalInputValue}
|
|
onInputChange={internalInputChangeHandler}
|
|
onMenuClose={() => setInternalInputValue('')}
|
|
isMulti={isMulti}
|
|
isDisabled={isDisabled || readOnly}
|
|
isLoading={isLoading}
|
|
isClearable={isClearable}
|
|
isRtl={isRtl}
|
|
isSearchable={isSearchable}
|
|
placeholder={placeholder}
|
|
closeMenuOnSelect={closeMenuOnSelect}
|
|
hideSelectedOptions={hideSelectedOptions}
|
|
className={cn('w-full flex-1', className?.select)}
|
|
classNames={{
|
|
control: ({ isFocused, isDisabled }) =>
|
|
cn('w-full border transition-shadow', 'rounded-lg!', {
|
|
'bg-base-100!': !isDisabled && !readOnly,
|
|
'bg-base-200! text-gray-400 cursor-not-allowed':
|
|
isDisabled && !readOnly,
|
|
'bg-transparent! cursor-not-allowed!': readOnly,
|
|
'cursor-pointer!': !readOnly && !isDisabled,
|
|
'border-red-500! ring-2 ring-red-200': isError,
|
|
'border-indigo-500 ring-2 ring-indigo-200':
|
|
isFocused && !startAdornment,
|
|
'border-base-content/10!': !isError && !isFocused,
|
|
'rounded-l-none!': inputPrefix && !startAdornment,
|
|
'rounded-r-none!': inputSuffix && !startAdornment,
|
|
}),
|
|
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
|
|
placeholder: () =>
|
|
cn({
|
|
'text-gray-400 text-sm leading-tight': !isError,
|
|
'text-red-300!': isError,
|
|
}),
|
|
singleValue: () =>
|
|
cn({
|
|
'm-0! text-gray-900 text-sm leading-tight': !isError,
|
|
'text-error!': isError,
|
|
'text-gray-900!': readOnly,
|
|
}),
|
|
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
|
|
indicatorsContainer: () =>
|
|
cn('flex items-center gap-1 pr-3 py-2'),
|
|
dropdownIndicator: ({ isFocused }) =>
|
|
cn('p-0! rounded hover:bg-gray-100', {
|
|
'text-gray-900': isFocused,
|
|
'text-gray-500': !isFocused,
|
|
'text-error!': isError,
|
|
}),
|
|
clearIndicator: () => cn('p-0! rounded hover:bg-gray-100'),
|
|
menu: () =>
|
|
cn(
|
|
'border border-base-content/5 rounded-xl! bg-base-100 shadow-lg! my-1.5!'
|
|
),
|
|
menuList: () => cn('p-0! max-h-60 overflow-auto'),
|
|
option: ({ isFocused, isSelected }) =>
|
|
cn('px-3 py-2 rounded-md cursor-pointer!', {
|
|
'bg-indigo-600 text-white': isFocused,
|
|
'bg-blue-500!': isSelected,
|
|
'text-gray-700': !isFocused && !isSelected,
|
|
}),
|
|
multiValue: ({ getValue, index }) => {
|
|
const selectedValues = getValue() as T[];
|
|
return cn(
|
|
'bg-base-200! rounded-lg! py-[3px] px-2.5 m-0! flex items-center gap-1! w-fit gap-2!',
|
|
selectedValues[index]?.className
|
|
);
|
|
},
|
|
multiValueRemove: () => cn('p-0! w-3 h-3'),
|
|
multiValueLabel: ({ getValue, index }) => {
|
|
const selectedValues = getValue() as T[];
|
|
return cn(
|
|
'p-0! text-base-content! text-xs!',
|
|
selectedValues[index]?.labelClassName
|
|
);
|
|
},
|
|
}}
|
|
components={{
|
|
...components,
|
|
...(optionComponent ? { Option: optionComponent } : {}),
|
|
MenuList: CustomMenuList,
|
|
}}
|
|
{...(startAdornment && {
|
|
shouldShowAdornment,
|
|
startAdornment,
|
|
})}
|
|
menuPortalTarget={
|
|
typeof document !== 'undefined'
|
|
? (menuPortalTarget ?? document.body)
|
|
: undefined
|
|
}
|
|
styles={{
|
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
|
multiValue(base) {
|
|
return {
|
|
...base,
|
|
borderRadius: '8px',
|
|
};
|
|
},
|
|
}}
|
|
onMenuScrollToBottom={onMenuScrollToBottom}
|
|
/>
|
|
|
|
{inputSuffix && (
|
|
<div
|
|
className={cn(
|
|
'inline-flex items-center px-3 border border-l-0 border-base-content/10 rounded-r-lg transition-all duration-200',
|
|
{
|
|
'bg-base-100 border-base-content/10': !isDisabled,
|
|
'bg-base-200 border-base-content/10': isDisabled,
|
|
'border-error': isError,
|
|
},
|
|
className?.inputSuffix
|
|
)}
|
|
>
|
|
{inputSuffix}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<SelectComponent<T, boolean, GroupBase<T>>
|
|
instanceId='select'
|
|
value={value ?? (isMulti ? [] : null)}
|
|
onChange={onChange ? handleChange : undefined}
|
|
options={options}
|
|
menuIsOpen={openMenu}
|
|
inputValue={internalInputValue}
|
|
onInputChange={internalInputChangeHandler}
|
|
onMenuClose={() => setInternalInputValue('')}
|
|
isMulti={isMulti}
|
|
isDisabled={isDisabled || readOnly}
|
|
isLoading={isLoading}
|
|
isClearable={isClearable}
|
|
isRtl={isRtl}
|
|
isSearchable={isSearchable}
|
|
placeholder={placeholder}
|
|
closeMenuOnSelect={closeMenuOnSelect}
|
|
hideSelectedOptions={hideSelectedOptions}
|
|
className={cn('w-full', className?.select)}
|
|
classNames={{
|
|
control: ({ isFocused, isDisabled }) =>
|
|
cn(
|
|
'w-full border transition-shadow',
|
|
// Gunakan rounded-lg untuk semua kasus
|
|
'rounded-lg!',
|
|
{
|
|
'bg-base-100!': !isDisabled && !readOnly,
|
|
'bg-base-200! text-gray-400 cursor-not-allowed':
|
|
isDisabled && !readOnly,
|
|
'bg-transparent! cursor-not-allowed!': readOnly,
|
|
'cursor-pointer!': !readOnly && !isDisabled,
|
|
'border-red-500! ring-2 ring-red-200': isError,
|
|
'border-indigo-500 ring-2 ring-indigo-200':
|
|
isFocused && !startAdornment,
|
|
'border-base-content/10!': !isError && !isFocused,
|
|
}
|
|
),
|
|
valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'),
|
|
placeholder: () =>
|
|
cn({
|
|
'text-gray-400 text-sm leading-tight': !isError,
|
|
'text-red-300!': isError,
|
|
}),
|
|
singleValue: () =>
|
|
cn({
|
|
'm-0! text-gray-900 text-sm leading-tight': !isError,
|
|
'text-error!': isError,
|
|
'text-gray-900!': readOnly,
|
|
}),
|
|
input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'),
|
|
indicatorsContainer: () => cn('flex items-center gap-1 pr-3 py-2'),
|
|
dropdownIndicator: ({ isFocused }) =>
|
|
cn('p-0! rounded hover:bg-gray-100', {
|
|
'text-gray-900': isFocused,
|
|
'text-gray-500': !isFocused,
|
|
'text-error!': isError,
|
|
}),
|
|
clearIndicator: () => cn('p-0! rounded hover:bg-gray-100'),
|
|
menu: () =>
|
|
cn(
|
|
'border border-base-content/5 rounded-xl! bg-base-100 shadow-lg! my-1.5!'
|
|
),
|
|
menuList: () => cn('p-0! max-h-60 overflow-auto'),
|
|
option: ({ isFocused, isSelected }) =>
|
|
cn('px-3 py-2 rounded-md cursor-pointer!', {
|
|
'bg-indigo-600 text-white': isFocused,
|
|
'bg-blue-500!': isSelected,
|
|
'text-gray-700': !isFocused && !isSelected,
|
|
}),
|
|
multiValue: ({ getValue, index }) => {
|
|
const selectedValues = getValue() as T[];
|
|
return cn(
|
|
'bg-base-200! rounded-lg! py-[3px] px-2.5 m-0! flex items-center gap-1! w-fit gap-2!',
|
|
selectedValues[index]?.className
|
|
);
|
|
},
|
|
multiValueRemove: () => cn('p-0! w-3 h-3'),
|
|
multiValueLabel: ({ getValue, index }) => {
|
|
const selectedValues = getValue() as T[];
|
|
return cn(
|
|
'p-0! text-base-content! text-xs!',
|
|
selectedValues[index]?.labelClassName
|
|
);
|
|
},
|
|
}}
|
|
components={{
|
|
...components,
|
|
...(optionComponent ? { Option: optionComponent } : {}),
|
|
MenuList: CustomMenuList,
|
|
}}
|
|
{...(startAdornment && {
|
|
shouldShowAdornment,
|
|
startAdornment,
|
|
})}
|
|
menuPortalTarget={
|
|
typeof document !== 'undefined'
|
|
? (menuPortalTarget ?? document.body)
|
|
: undefined
|
|
}
|
|
styles={{
|
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
|
multiValue(base) {
|
|
return {
|
|
...base,
|
|
borderRadius: '8px',
|
|
};
|
|
},
|
|
}}
|
|
onMenuScrollToBottom={onMenuScrollToBottom}
|
|
/>
|
|
)}
|
|
|
|
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
|
{!isError && bottomLabel && (
|
|
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const useSelect = <T,>(
|
|
basePath: string | null,
|
|
valueKey: keyof T | string,
|
|
labelKey: keyof T | string,
|
|
searchKey: string = 'search',
|
|
params?: { [key: string]: string }
|
|
) => {
|
|
const [inputValue, setInputValue] = useState('');
|
|
|
|
const pageKey = 'page';
|
|
const limitKey = 'limit';
|
|
const limit = params?.['limit'] ?? 10;
|
|
|
|
const getKey = (
|
|
pageIndex: number,
|
|
previousPageData?: BaseApiResponse<T[]>
|
|
) => {
|
|
// stop when backend says no more pages
|
|
if (previousPageData && isResponseSuccess(previousPageData)) {
|
|
const meta = previousPageData.meta;
|
|
if (meta && meta.page >= meta.total_pages) return null;
|
|
}
|
|
|
|
const qs = new URLSearchParams({
|
|
...(params ?? {}),
|
|
[searchKey]: inputValue ?? '',
|
|
[pageKey]: String(pageIndex + 1),
|
|
[limitKey]: String(limit),
|
|
}).toString();
|
|
|
|
return basePath ? `${basePath}?${qs}` : null;
|
|
};
|
|
|
|
const {
|
|
data: pages,
|
|
isLoading,
|
|
isValidating,
|
|
size,
|
|
setSize,
|
|
} = useSWRInfinite<BaseApiResponse<T[]>>(getKey, (url) =>
|
|
httpClientFetcher<BaseApiResponse<T[]>>(url)
|
|
);
|
|
|
|
const options = useMemo(() => {
|
|
if (!pages) return [];
|
|
|
|
return pages.flatMap((page) =>
|
|
isResponseSuccess(page)
|
|
? page.data.map((item) => ({
|
|
value: getByPath<T, number>(item, valueKey as string),
|
|
label: getByPath<T, string>(item, labelKey as string),
|
|
}))
|
|
: []
|
|
);
|
|
}, [pages, valueKey, labelKey]);
|
|
|
|
const lastPage = pages?.[pages.length - 1];
|
|
const hasMore =
|
|
!!lastPage &&
|
|
isResponseSuccess(lastPage) &&
|
|
!!lastPage.meta &&
|
|
lastPage.meta.page < lastPage.meta.total_pages;
|
|
|
|
const loadMore = () => {
|
|
if (!hasMore) return;
|
|
setSize(size + 1);
|
|
};
|
|
|
|
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
|
|
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
|
|
|
|
const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
|
|
|
|
if (isResponseSuccess(pages?.[latestPagesIndex])) {
|
|
formattedSuccessRawData = {
|
|
...pages?.[latestPagesIndex],
|
|
data:
|
|
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
|
|
[],
|
|
};
|
|
}
|
|
|
|
if (isResponseError(pages?.[latestPagesIndex])) {
|
|
formattedErrorRawData = pages?.[latestPagesIndex];
|
|
}
|
|
|
|
return {
|
|
inputValue,
|
|
setInputValue,
|
|
|
|
options,
|
|
rawData: isResponseSuccess(pages?.[latestPagesIndex])
|
|
? formattedSuccessRawData
|
|
: formattedErrorRawData,
|
|
|
|
isLoadingOptions: isLoading || isValidating,
|
|
isLoadingMore: isValidating && size > 1,
|
|
hasMore,
|
|
loadMore,
|
|
};
|
|
};
|
|
|
|
export { useSelect };
|
|
export default SelectInput;
|