Skip to content

Commit b9ae92e

Browse files
authored
Merge pull request #384 from dDevAhmed/feature/issue-371-tag-selector
feat: Implement TagSelector Component for Event Tagging
2 parents 8f04775 + fbd7075 commit b9ae92e

7 files changed

Lines changed: 431 additions & 1 deletion

File tree

app/frontend/components/forms/CreateEventForm.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ import { zodResolver } from '@hookform/resolvers/zod';
55
import { z } from 'zod';
66
import FormInput from './FormInput';
77
import ErrorSummary from './ErrorSummary';
8+
import { TagSelector } from '@/components/ui/atoms';
9+
10+
const TAG_SUGGESTIONS = [
11+
'Web3',
12+
'Blockchain',
13+
'Stellar',
14+
'Soroban',
15+
'Hackathon',
16+
'Workshop',
17+
'Networking',
18+
'Startup',
19+
'AI',
20+
'DeFi',
21+
'Community',
22+
'Technology',
23+
];
824

925
// ─── Schema ───────────────────────────────────────────────────────────────────
1026
const createEventSchema = z.object({
@@ -29,6 +45,12 @@ const createEventSchema = z.object({
2945
category: z.enum(['conference', 'workshop', 'concert', 'hackathon', 'meetup', ''], {
3046
errorMap: () => ({ message: 'Please select a category' }),
3147
}).refine(v => v !== '', { message: 'Please select a category' }),
48+
tags: z
49+
.array(z.string().trim().min(1).max(20, 'Tag cannot exceed 20 characters'))
50+
.max(10, 'You can add up to 10 tags')
51+
.refine((tags) => new Set(tags.map((tag) => tag.toLowerCase())).size === tags.length, {
52+
message: 'Duplicate tags are not allowed',
53+
}),
3254
isPublic: z.boolean(),
3355
websiteUrl: z
3456
.string()
@@ -46,6 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
4668
ticketPrice: 'Ticket Price',
4769
maxAttendees: 'Max Attendees',
4870
category: 'Category',
71+
tags: 'Tags',
4972
isPublic: 'Visibility',
5073
websiteUrl: 'Website URL',
5174
};
@@ -101,6 +124,7 @@ export default function CreateEventForm() {
101124
ticketPrice: '0',
102125
maxAttendees: '100',
103126
category: '',
127+
tags: [],
104128
isPublic: true,
105129
websiteUrl: '',
106130
},
@@ -193,6 +217,23 @@ export default function CreateEventForm() {
193217
<option value="meetup">Meetup</option>
194218
</FormInput>
195219

220+
{/* Tags */}
221+
<Controller
222+
name="tags"
223+
control={control}
224+
render={({ field }) => (
225+
<TagSelector
226+
label="Tags"
227+
name={field.name}
228+
value={field.value}
229+
onChange={field.onChange}
230+
suggestions={TAG_SUGGESTIONS}
231+
hint="Press Enter or comma to add tags. Backspace removes the last tag."
232+
error={errors.tags}
233+
/>
234+
)}
235+
/>
236+
196237
{/* Price + Attendees side by side */}
197238
<div className="grid grid-cols-2 gap-4">
198239
<FormInput
@@ -268,7 +309,7 @@ export default function CreateEventForm() {
268309
role="switch"
269310
aria-checked={field.value}
270311
onClick={() => field.onChange(!field.value)}
271-
className={`relative w-11 h-6 rounded-full transition-all duration-200 flex-shrink-0 ${
312+
className={`relative w-11 h-6 rounded-full transition-all duration-200 shrink-0 ${
272313
field.value ? 'bg-[#3d5afe]' : 'bg-[#1e2333]'
273314
}`}
274315
>

app/frontend/components/forms/schema/eventSchema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ export const createEventSchema = z.object({
4747

4848
websiteUrl: z.string().optional().or(z.literal('')),
4949

50+
tags: z
51+
.array(z.string().trim().min(1, 'Tags cannot be empty').max(20, 'Tag cannot exceed 20 characters'))
52+
.max(10, 'You can add up to 10 tags')
53+
.refine((tags) => new Set(tags.map((tag) => tag.toLowerCase())).size === tags.length, {
54+
message: 'Tags must be unique',
55+
}),
56+
5057
isPublic: z.boolean().default(true),
5158
});
5259

@@ -61,6 +68,7 @@ export const EVENT_FIELD_LABELS: Record<string, string> = {
6168
maxAttendees: 'Max Attendees',
6269
contractAddress: 'Contract Address',
6370
websiteUrl: 'Website URL',
71+
tags: 'Tags',
6472
isPublic: 'Visibility',
6573
};
6674

@@ -73,5 +81,6 @@ export const createEventDefaults: CreateEventFormValues = {
7381
maxAttendees: '100',
7482
contractAddress: '',
7583
websiteUrl: '',
84+
tags: [],
7685
isPublic: true,
7786
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { useState } from 'react';
3+
import { TagSelector } from './TagSelector';
4+
5+
const meta: Meta<typeof TagSelector> = {
6+
title: 'UI/Atoms/TagSelector',
7+
component: TagSelector,
8+
tags: ['autodocs'],
9+
};
10+
11+
export default meta;
12+
type Story = StoryObj<typeof meta>;
13+
14+
const DEFAULT_SUGGESTIONS = [
15+
'Web3',
16+
'Stellar',
17+
'Hackathon',
18+
'Networking',
19+
'Workshop',
20+
'Community',
21+
'Technology',
22+
'Startup',
23+
];
24+
25+
export const Default: Story = {
26+
render: (args) => {
27+
const [tags, setTags] = useState<string[]>(['Stellar']);
28+
29+
return (
30+
<div className="w-[520px]">
31+
<TagSelector
32+
{...args}
33+
value={tags}
34+
onChange={setTags}
35+
/>
36+
</div>
37+
);
38+
},
39+
args: {
40+
label: 'Event Tags',
41+
suggestions: DEFAULT_SUGGESTIONS,
42+
hint: 'Press Enter to add. Use Backspace to remove the last tag.',
43+
},
44+
};
45+
46+
export const WithError: Story = {
47+
render: (args) => {
48+
const [tags, setTags] = useState<string[]>([]);
49+
50+
return (
51+
<div className="w-[520px]">
52+
<TagSelector
53+
{...args}
54+
value={tags}
55+
onChange={setTags}
56+
error={{ message: 'At least one tag is required' }}
57+
/>
58+
</div>
59+
);
60+
},
61+
args: {
62+
label: 'Event Tags',
63+
suggestions: DEFAULT_SUGGESTIONS,
64+
},
65+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { TagSelector } from './TagSelector';
4+
5+
describe('TagSelector', () => {
6+
it('adds tags with Enter key', () => {
7+
const onChange = vi.fn();
8+
render(<TagSelector label="Tags" value={[]} onChange={onChange} />);
9+
10+
const input = screen.getByLabelText('Tags');
11+
fireEvent.change(input, { target: { value: 'Stellar' } });
12+
fireEvent.keyDown(input, { key: 'Enter' });
13+
14+
expect(onChange).toHaveBeenCalledWith(['Stellar']);
15+
});
16+
17+
it('prevents duplicate tags', () => {
18+
const onChange = vi.fn();
19+
render(<TagSelector label="Tags" value={['Stellar']} onChange={onChange} />);
20+
21+
const input = screen.getByLabelText('Tags');
22+
fireEvent.change(input, { target: { value: 'stellar' } });
23+
fireEvent.keyDown(input, { key: 'Enter' });
24+
25+
expect(onChange).not.toHaveBeenCalled();
26+
expect(screen.getByText('Tag already added')).toBeInTheDocument();
27+
});
28+
29+
it('removes last tag on Backspace when input is empty', () => {
30+
const onChange = vi.fn();
31+
render(<TagSelector label="Tags" value={['Web3', 'Stellar']} onChange={onChange} />);
32+
33+
const input = screen.getByLabelText('Tags');
34+
fireEvent.keyDown(input, { key: 'Backspace' });
35+
36+
expect(onChange).toHaveBeenCalledWith(['Web3']);
37+
});
38+
39+
it('shows suggestions and allows selecting one', () => {
40+
const onChange = vi.fn();
41+
render(
42+
<TagSelector
43+
label="Tags"
44+
value={[]}
45+
onChange={onChange}
46+
suggestions={['Hackathon', 'Workshop']}
47+
/>
48+
);
49+
50+
const input = screen.getByLabelText('Tags');
51+
fireEvent.focus(input);
52+
fireEvent.change(input, { target: { value: 'work' } });
53+
54+
const suggestion = screen.getByRole('option', { name: 'Workshop' });
55+
fireEvent.mouseDown(suggestion);
56+
57+
expect(onChange).toHaveBeenCalledWith(['Workshop']);
58+
});
59+
});

0 commit comments

Comments
 (0)