Skip to content

Commit e453aaf

Browse files
Add entity alias support - display, create, and edit functionality
Co-authored-by: geoff-maddock <[email protected]>
1 parent 48d7624 commit e453aaf

File tree

6 files changed

+355
-0
lines changed

6 files changed

+355
-0
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
import { StringMultiInput } from '../../components/StringMultiInput';
5+
6+
describe('StringMultiInput', () => {
7+
it('renders with label', () => {
8+
const onChange = vi.fn();
9+
render(
10+
<StringMultiInput
11+
label="Test Label"
12+
value={[]}
13+
onChange={onChange}
14+
/>
15+
);
16+
17+
expect(screen.getByText('Test Label')).toBeInTheDocument();
18+
});
19+
20+
it('displays placeholder when empty', () => {
21+
const onChange = vi.fn();
22+
render(
23+
<StringMultiInput
24+
label="Test"
25+
value={[]}
26+
onChange={onChange}
27+
placeholder="Type something..."
28+
/>
29+
);
30+
31+
const input = screen.getByPlaceholderText('Type something...');
32+
expect(input).toBeInTheDocument();
33+
});
34+
35+
it('displays existing values as tags', () => {
36+
const onChange = vi.fn();
37+
const values = ['Alias 1', 'Alias 2', 'Alias 3'];
38+
39+
render(
40+
<StringMultiInput
41+
label="Aliases"
42+
value={values}
43+
onChange={onChange}
44+
/>
45+
);
46+
47+
values.forEach(value => {
48+
expect(screen.getByText(value)).toBeInTheDocument();
49+
});
50+
});
51+
52+
it('adds a new value when Enter is pressed', () => {
53+
const onChange = vi.fn();
54+
render(
55+
<StringMultiInput
56+
label="Test"
57+
value={['Existing']}
58+
onChange={onChange}
59+
/>
60+
);
61+
62+
const input = screen.getByRole('textbox');
63+
fireEvent.change(input, { target: { value: 'New Value' } });
64+
fireEvent.keyDown(input, { key: 'Enter' });
65+
66+
expect(onChange).toHaveBeenCalledWith(['Existing', 'New Value']);
67+
});
68+
69+
it('does not add duplicate values', () => {
70+
const onChange = vi.fn();
71+
render(
72+
<StringMultiInput
73+
label="Test"
74+
value={['Existing']}
75+
onChange={onChange}
76+
/>
77+
);
78+
79+
const input = screen.getByRole('textbox');
80+
fireEvent.change(input, { target: { value: 'Existing' } });
81+
fireEvent.keyDown(input, { key: 'Enter' });
82+
83+
expect(onChange).not.toHaveBeenCalled();
84+
});
85+
86+
it('trims whitespace when adding values', () => {
87+
const onChange = vi.fn();
88+
render(
89+
<StringMultiInput
90+
label="Test"
91+
value={[]}
92+
onChange={onChange}
93+
/>
94+
);
95+
96+
const input = screen.getByRole('textbox');
97+
fireEvent.change(input, { target: { value: ' Trimmed Value ' } });
98+
fireEvent.keyDown(input, { key: 'Enter' });
99+
100+
expect(onChange).toHaveBeenCalledWith(['Trimmed Value']);
101+
});
102+
103+
it('does not add empty values', () => {
104+
const onChange = vi.fn();
105+
render(
106+
<StringMultiInput
107+
label="Test"
108+
value={[]}
109+
onChange={onChange}
110+
/>
111+
);
112+
113+
const input = screen.getByRole('textbox');
114+
fireEvent.change(input, { target: { value: ' ' } });
115+
fireEvent.keyDown(input, { key: 'Enter' });
116+
117+
expect(onChange).not.toHaveBeenCalled();
118+
});
119+
120+
it('removes value when X button is clicked', () => {
121+
const onChange = vi.fn();
122+
render(
123+
<StringMultiInput
124+
label="Test"
125+
value={['Value 1', 'Value 2', 'Value 3']}
126+
onChange={onChange}
127+
/>
128+
);
129+
130+
const removeButtons = screen.getAllByRole('button', { name: /Remove/i });
131+
fireEvent.click(removeButtons[1]); // Remove "Value 2"
132+
133+
expect(onChange).toHaveBeenCalledWith(['Value 1', 'Value 3']);
134+
});
135+
136+
it('removes last value when Backspace is pressed with empty input', () => {
137+
const onChange = vi.fn();
138+
render(
139+
<StringMultiInput
140+
label="Test"
141+
value={['Value 1', 'Value 2']}
142+
onChange={onChange}
143+
/>
144+
);
145+
146+
const input = screen.getByRole('textbox');
147+
fireEvent.keyDown(input, { key: 'Backspace' });
148+
149+
expect(onChange).toHaveBeenCalledWith(['Value 1']);
150+
});
151+
152+
it('does not remove value when Backspace is pressed with text in input', () => {
153+
const onChange = vi.fn();
154+
render(
155+
<StringMultiInput
156+
label="Test"
157+
value={['Value 1', 'Value 2']}
158+
onChange={onChange}
159+
/>
160+
);
161+
162+
const input = screen.getByRole('textbox');
163+
fireEvent.change(input, { target: { value: 'some text' } });
164+
fireEvent.keyDown(input, { key: 'Backspace' });
165+
166+
expect(onChange).not.toHaveBeenCalled();
167+
});
168+
169+
it('respects disabled prop', () => {
170+
const onChange = vi.fn();
171+
render(
172+
<StringMultiInput
173+
label="Test"
174+
value={['Value 1']}
175+
onChange={onChange}
176+
disabled={true}
177+
/>
178+
);
179+
180+
const input = screen.getByRole('textbox');
181+
expect(input).toBeDisabled();
182+
183+
fireEvent.change(input, { target: { value: 'New Value' } });
184+
fireEvent.keyDown(input, { key: 'Enter' });
185+
186+
expect(onChange).not.toHaveBeenCalled();
187+
});
188+
189+
it('clears input after adding a value', () => {
190+
const onChange = vi.fn();
191+
render(
192+
<StringMultiInput
193+
label="Test"
194+
value={[]}
195+
onChange={onChange}
196+
/>
197+
);
198+
199+
const input = screen.getByRole('textbox') as HTMLInputElement;
200+
fireEvent.change(input, { target: { value: 'New Value' } });
201+
fireEvent.keyDown(input, { key: 'Enter' });
202+
203+
expect(input.value).toBe('');
204+
});
205+
});

src/components/EntityDetail.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,22 @@ export default function EntityDetail({ entitySlug, initialEntity }: { entitySlug
432432
instagramUsername={entity.instagram_username}
433433
/>
434434

435+
{entity.aliases && entity.aliases.length > 0 && (
436+
<div className="space-y-2">
437+
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Aliases</div>
438+
<div className="flex flex-wrap gap-2">
439+
{entity.aliases.map((alias, index) => (
440+
<span
441+
key={index}
442+
className="inline-flex items-center px-2 py-1 rounded text-sm bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
443+
>
444+
{alias}
445+
</span>
446+
))}
447+
</div>
448+
</div>
449+
)}
450+
435451
{/* Deprecated inline links list is replaced by the EntityLinks card below */}
436452
{entity.tags.length > 0 && (
437453
<div className="space-y-2">
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import React, { useState, useRef } from 'react';
2+
import { X } from 'lucide-react';
3+
import { Input } from '@/components/ui/input';
4+
import { Label } from '@/components/ui/label';
5+
6+
interface StringMultiInputProps {
7+
label: string;
8+
value: string[];
9+
onChange: (values: string[]) => void;
10+
placeholder?: string;
11+
className?: string;
12+
disabled?: boolean;
13+
}
14+
15+
export const StringMultiInput: React.FC<StringMultiInputProps> = ({
16+
label,
17+
value,
18+
onChange,
19+
placeholder = 'Type and press Enter...',
20+
className = '',
21+
disabled = false,
22+
}) => {
23+
const [inputValue, setInputValue] = useState('');
24+
const inputRef = useRef<HTMLInputElement>(null);
25+
26+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
27+
setInputValue(e.target.value);
28+
};
29+
30+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
31+
if (disabled) return;
32+
33+
if (e.key === 'Enter' && inputValue.trim()) {
34+
e.preventDefault();
35+
const newValue = inputValue.trim();
36+
// Only add if not already in the list
37+
if (!value.includes(newValue)) {
38+
onChange([...value, newValue]);
39+
}
40+
setInputValue('');
41+
} else if (e.key === 'Backspace' && inputValue === '' && value.length > 0) {
42+
e.preventDefault();
43+
const newValues = [...value];
44+
newValues.pop();
45+
onChange(newValues);
46+
}
47+
};
48+
49+
const handleRemoveItem = (indexToRemove: number) => {
50+
if (disabled) return;
51+
const newValues = value.filter((_, index) => index !== indexToRemove);
52+
onChange(newValues);
53+
inputRef.current?.focus();
54+
};
55+
56+
return (
57+
<div className={`space-y-2 ${className}`}>
58+
<Label htmlFor={`string-multi-input-${label}`}>{label}</Label>
59+
60+
<div
61+
className={`
62+
flex flex-wrap items-center gap-1 p-2 border rounded-md bg-white dark:bg-slate-800
63+
border-gray-300 dark:border-slate-600 min-h-[2.5rem] cursor-text
64+
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400 dark:hover:border-slate-500'}
65+
`}
66+
onClick={() => inputRef.current?.focus()}
67+
>
68+
{/* Display selected values as tags */}
69+
{value.map((item, index) => (
70+
<span
71+
key={index}
72+
className="inline-flex items-center px-2 py-1 rounded text-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
73+
>
74+
{item}
75+
{!disabled && (
76+
<button
77+
type="button"
78+
onClick={(e) => {
79+
e.stopPropagation();
80+
handleRemoveItem(index);
81+
}}
82+
className="ml-1 hover:text-red-600 dark:hover:text-red-400 focus:outline-none"
83+
aria-label={`Remove ${item}`}
84+
>
85+
<X size={14} />
86+
</button>
87+
)}
88+
</span>
89+
))}
90+
91+
{/* Input field */}
92+
<Input
93+
ref={inputRef}
94+
id={`string-multi-input-${label}`}
95+
type="text"
96+
value={inputValue}
97+
onChange={handleInputChange}
98+
onKeyDown={handleKeyDown}
99+
placeholder={value.length === 0 ? placeholder : ''}
100+
disabled={disabled}
101+
className="flex-1 min-w-[120px] border-0 outline-none bg-transparent text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus-visible:ring-0 p-0 h-auto"
102+
/>
103+
</div>
104+
</div>
105+
);
106+
};
107+
108+
export default StringMultiInput;

src/routes/entity-create.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
77
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
88
import AjaxSelect from '../components/AjaxSelect';
99
import AjaxMultiSelect from '../components/AjaxMultiSelect';
10+
import StringMultiInput from '../components/StringMultiInput';
1011
import { api } from '@/lib/api';
1112
import { handleFormError } from '@/lib/errorHandler';
1213
import { useSlug } from '@/hooks/useSlug';
@@ -30,6 +31,7 @@ const EntityCreate: React.FC = () => {
3031
primary_location_id: '' as number | '',
3132
tag_list: [] as number[],
3233
role_list: [] as number[],
34+
aliases: [] as string[],
3335
});
3436
const { name, slug, setName, setSlug, manuallyOverridden } = useSlug('', '');
3537

@@ -125,6 +127,7 @@ const EntityCreate: React.FC = () => {
125127
started_at: formData.started_at ? `${formData.started_at}:00` : undefined,
126128
tag_list: formData.tag_list,
127129
role_list: formData.role_list,
130+
aliases: formData.aliases,
128131
};
129132
const { data } = await api.post('/entities', payload);
130133
navigate({ to: '/entities/$entitySlug', params: { entitySlug: data.slug } });
@@ -312,6 +315,15 @@ const EntityCreate: React.FC = () => {
312315
placeholder="Type to add role..."
313316
/>
314317
</div>
318+
<div className="space-y-2">
319+
<StringMultiInput
320+
label="Aliases"
321+
value={formData.aliases}
322+
onChange={(aliases) => setFormData(p => ({ ...p, aliases }))}
323+
placeholder="Type an alias and press Enter..."
324+
/>
325+
{renderError('aliases')}
326+
</div>
315327
<Button type="submit" className="w-full">Create Entity</Button>
316328
</form>
317329
</div>

0 commit comments

Comments
 (0)