Readur/frontend/src/components/Labels/LabelSelector.tsx

273 lines
8.6 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import {
Autocomplete,
TextField,
Chip,
Box,
Paper,
Typography,
Divider,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
} from '@mui/material';
import { Add as AddIcon, Edit as EditIcon } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import Label, { type LabelData } from './Label';
import LabelCreateDialog from './LabelCreateDialog';
interface LabelSelectorProps {
selectedLabels: LabelData[];
availableLabels: LabelData[];
onLabelsChange: (labels: LabelData[]) => void;
onCreateLabel?: (labelData: Omit<LabelData, 'id' | 'is_system' | 'created_at' | 'updated_at' | 'document_count' | 'source_count'>) => Promise<LabelData>;
placeholder?: string;
size?: 'small' | 'medium';
disabled?: boolean;
multiple?: boolean;
showCreateButton?: boolean;
maxTags?: number;
}
const LabelSelector: React.FC<LabelSelectorProps> = ({
selectedLabels,
availableLabels,
onLabelsChange,
onCreateLabel,
placeholder = "Search or create labels...",
size = 'medium',
disabled = false,
multiple = true,
showCreateButton = true,
maxTags
}) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [prefilledName, setPrefilledName] = useState('');
// Memoize filtered options for performance
const filteredOptions = useMemo(() => {
const selectedIds = new Set(selectedLabels.map(label => label.id));
return availableLabels.filter(label => !selectedIds.has(label.id));
}, [availableLabels, selectedLabels]);
// Group options by system vs user labels
const groupedOptions = useMemo(() => {
const systemLabels = filteredOptions.filter(label => label.is_system);
const userLabels = filteredOptions.filter(label => !label.is_system);
return [
...(systemLabels.length > 0 ? [{ group: 'System Labels', options: systemLabels }] : []),
...(userLabels.length > 0 ? [{ group: 'My Labels', options: userLabels }] : [])
];
}, [filteredOptions]);
const handleLabelChange = (event: any, newValue: LabelData | LabelData[] | null) => {
if (!multiple) {
onLabelsChange(newValue ? [newValue as LabelData] : []);
return;
}
const newLabels = newValue as LabelData[] || [];
// Check max tags limit
if (maxTags && newLabels.length > maxTags) {
return;
}
onLabelsChange(newLabels);
};
const handleCreateNew = () => {
setPrefilledName(inputValue);
setCreateDialogOpen(true);
};
const handleCreateLabel = async (labelData: Omit<LabelData, 'id' | 'is_system' | 'created_at' | 'updated_at' | 'document_count' | 'source_count'>) => {
if (onCreateLabel) {
try {
const newLabel = await onCreateLabel(labelData);
onLabelsChange([...selectedLabels, newLabel]);
setCreateDialogOpen(false);
setInputValue('');
setPrefilledName('');
} catch (error) {
console.error('Failed to create label:', error);
}
}
};
const canCreateNew = inputValue.trim() &&
!availableLabels.some(label =>
label.name.toLowerCase() === inputValue.trim().toLowerCase()
) &&
onCreateLabel &&
showCreateButton;
return (
<>
<Autocomplete<LabelData, boolean, false, false>
multiple={multiple}
value={multiple ? selectedLabels : selectedLabels[0] || null}
onChange={handleLabelChange}
inputValue={inputValue}
onInputChange={(event, newInputValue) => setInputValue(newInputValue)}
options={filteredOptions}
groupBy={(option: LabelData) => option.is_system ? t('labels.selector.systemLabels') : t('labels.selector.myLabels')}
getOptionLabel={(option: LabelData) => option.name}
isOptionEqualToValue={(option: LabelData, value: LabelData) => option.id === value.id}
disabled={disabled}
size={size}
renderInput={(params) => (
<TextField
{...params}
placeholder={selectedLabels.length === 0 ? placeholder : ''}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{canCreateNew && (
<Tooltip title={t('labels.selector.createLabel', { name: inputValue })}>
<IconButton
size="small"
onClick={handleCreateNew}
sx={{ mr: 1 }}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
renderTags={(tagValue, getTagProps) =>
tagValue.map((option, index) => {
const tagProps = getTagProps({ index });
const { key, ...restTagProps } = tagProps;
return (
<Label
key={option.id}
label={option}
size="small"
deletable={!disabled}
onDelete={() => {
const newLabels = tagValue.filter((_, i) => i !== index);
onLabelsChange(newLabels);
}}
{...restTagProps}
/>
);
})
}
renderOption={(props, option, { selected }) => {
const { key, ...restProps } = props;
return (
<Box component="li" key={option.id} {...restProps}>
<Label
label={option}
size="small"
showCount
variant={selected ? 'filled' : 'outlined'}
/>
</Box>
);
}}
renderGroup={(params) => (
<Box key={params.key}>
<Typography
variant="caption"
sx={{
px: 2,
py: 1,
color: 'text.secondary',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}
>
{params.group}
</Typography>
<Box>{params.children}</Box>
{params.group === 'System Labels' && <Divider sx={{ my: 1 }} />}
</Box>
)}
PaperComponent={({ children, ...paperProps }) => (
<Paper {...paperProps}>
{children}
{canCreateNew && (
<>
<Divider />
<Box
sx={{
p: 1,
cursor: 'pointer',
'&:hover': { backgroundColor: 'action.hover' }
}}
onClick={handleCreateNew}
>
<Box display="flex" alignItems="center" gap={1}>
<AddIcon fontSize="small" color="primary" />
<Typography variant="body2" color="primary">
{t('labels.selector.createLabel', { name: inputValue })}
</Typography>
</Box>
</Box>
</>
)}
</Paper>
)}
noOptionsText={
inputValue.trim() ? (
canCreateNew ? (
<Box>
<Typography variant="body2" color="text.secondary">
{t('labels.selector.noLabelsFound')}
</Typography>
<Button
startIcon={<AddIcon />}
onClick={handleCreateNew}
size="small"
sx={{ mt: 1 }}
>
{t('labels.selector.createLabel', { name: inputValue })}
</Button>
</Box>
) : (
t('labels.selector.noLabelsMatch', { query: inputValue })
)
) : t('labels.selector.noLabelsAvailable')
}
filterOptions={(options, { inputValue }) => {
if (!inputValue) return options;
return options.filter(option =>
option.name.toLowerCase().includes(inputValue.toLowerCase()) ||
(option.description && option.description.toLowerCase().includes(inputValue.toLowerCase()))
);
}}
/>
{onCreateLabel && (
<LabelCreateDialog
open={createDialogOpen}
onClose={() => {
setCreateDialogOpen(false);
setPrefilledName('');
}}
onSubmit={handleCreateLabel}
prefilledName={prefilledName}
/>
)}
</>
);
};
export default LabelSelector;