Files
mtdb_movie/common/resources/client/ui/forms/normalized-model-field.tsx
maher 703f50a09d
Some checks failed
Build / run (push) Has been cancelled
first commit
2025-10-29 11:42:25 +01:00

244 lines
6.6 KiB
TypeScript
Executable File

import React, {ReactNode, useRef, useState} from 'react';
import {useTrans} from '../../i18n/use-trans';
import {Trans} from '../../i18n/trans';
import {Avatar} from '../images/avatar';
import {Tooltip} from '../tooltip/tooltip';
import {IconButton} from '../buttons/icon-button';
import {EditIcon} from '../../icons/material/Edit';
import {message} from '../../i18n/message';
import {Item} from './listbox/item';
import {useController, useFormContext} from 'react-hook-form';
import {useControlledState} from '@react-stately/utils';
import {getInputFieldClassNames} from './input-field/get-input-field-class-names';
import clsx from 'clsx';
import {Skeleton} from '../skeleton/skeleton';
import {useNormalizedModels} from '../../users/queries/use-normalized-models';
import {useNormalizedModel} from '../../users/queries/use-normalized-model';
import {AnimatePresence, m} from 'framer-motion';
import {opacityAnimation} from '../animation/opacity-animation';
import {Select} from '@common/ui/forms/select/select';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
import {BaseFieldProps} from '@common/ui/forms/input-field/base-field-props';
interface NormalizedModelFieldProps {
label?: ReactNode;
className?: string;
background?: BaseFieldProps['background'];
value?: string | number;
placeholder?: MessageDescriptor;
searchPlaceholder?: MessageDescriptor;
defaultValue?: string | number;
onChange?: (value: string | number) => void;
invalid?: boolean;
errorMessage?: string;
description?: ReactNode;
autoFocus?: boolean;
queryParams?: Record<string, string>;
endpoint: string;
disabled?: boolean;
required?: boolean;
}
export function NormalizedModelField({
label,
className,
background,
value,
defaultValue = '',
placeholder = message('Select item...'),
searchPlaceholder = message('Find an item...'),
onChange,
description,
errorMessage,
invalid,
autoFocus,
queryParams,
endpoint,
disabled,
required,
}: NormalizedModelFieldProps) {
const inputRef = useRef<HTMLButtonElement>(null);
const [inputValue, setInputValue] = useState('');
const [selectedValue, setSelectedValue] = useControlledState(
value,
defaultValue,
onChange,
);
const query = useNormalizedModels(endpoint, {
query: inputValue,
...queryParams,
});
const {trans} = useTrans();
const fieldClassNames = getInputFieldClassNames({size: 'md'});
if (selectedValue) {
return (
<div className={className}>
<div className={fieldClassNames.label}>{label}</div>
<div
className={clsx(
'rounded-input border p-8',
background,
invalid && 'border-danger',
)}
>
<AnimatePresence initial={false} mode="wait">
<SelectedModelPreview
disabled={disabled}
endpoint={endpoint}
modelId={selectedValue}
queryParams={queryParams}
onEditClick={() => {
setSelectedValue('');
setInputValue('');
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.click();
});
}}
/>
</AnimatePresence>
</div>
{description && !errorMessage && (
<div className={fieldClassNames.description}>{description}</div>
)}
{errorMessage && (
<div className={fieldClassNames.error}>{errorMessage}</div>
)}
</div>
);
}
return (
<Select
className={className}
showSearchField
invalid={invalid}
errorMessage={errorMessage}
description={description}
color="white"
isAsync
background={background}
placeholder={trans(placeholder)}
searchPlaceholder={trans(searchPlaceholder)}
label={label}
isLoading={query.isFetching}
items={query.data?.results}
inputValue={inputValue}
onInputValueChange={setInputValue}
selectionMode="single"
selectedValue={selectedValue}
onSelectionChange={setSelectedValue}
ref={inputRef}
autoFocus={autoFocus}
disabled={disabled}
required={required}
>
{model => (
<Item
value={model.id}
key={model.id}
description={model.description}
startIcon={<Avatar src={model.image} />}
>
{model.name}
</Item>
)}
</Select>
);
}
interface SelectedModelPreviewProps {
modelId: string | number;
selectedValue?: number | string;
onEditClick?: () => void;
endpoint?: string;
disabled?: boolean;
queryParams?: NormalizedModelFieldProps['queryParams'];
}
function SelectedModelPreview({
modelId,
onEditClick,
endpoint,
disabled,
queryParams,
}: SelectedModelPreviewProps) {
const {data, isLoading} = useNormalizedModel(
`${endpoint}/${modelId}`,
queryParams,
);
if (isLoading || !data?.model) {
return <LoadingSkeleton key="skeleton" />;
}
return (
<m.div
className={clsx(
'flex items-center gap-10',
disabled && 'pointer-events-none cursor-not-allowed text-disabled',
)}
key="preview"
{...opacityAnimation}
>
{data.model.image && <Avatar src={data.model.image} />}
<div>
<div className="text-sm leading-4">{data.model.name}</div>
<div className="text-xs text-muted">{data.model.description}</div>
</div>
<Tooltip label={<Trans message="Change item" />}>
<IconButton
className="ml-auto text-muted"
size="sm"
onClick={onEditClick}
disabled={disabled}
>
<EditIcon />
</IconButton>
</Tooltip>
</m.div>
);
}
function LoadingSkeleton() {
return (
<m.div className="flex items-center gap-10" {...opacityAnimation}>
<Skeleton variant="rect" size="w-32 h-32" />
<div className="max-h-[36px] flex-auto">
<Skeleton className="text-xs" />
<Skeleton className="max-h-8 text-xs" />
</div>
<Skeleton variant="icon" size="w-24 h-24" />
</m.div>
);
}
interface FormNormalizedModelFieldProps extends NormalizedModelFieldProps {
name: string;
}
export function FormNormalizedModelField({
name,
...fieldProps
}: FormNormalizedModelFieldProps) {
const {clearErrors} = useFormContext();
const {
field: {onChange, value = ''},
fieldState: {invalid, error},
} = useController({
name,
});
return (
<NormalizedModelField
value={value}
onChange={value => {
onChange(value);
clearErrors(name);
}}
invalid={invalid}
errorMessage={error?.message}
{...fieldProps}
/>
);
}