273 lines
8.6 KiB
TypeScript
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; |