feat: Add tag suggestion dropdown for Notes and Tasks (#12)

* feat: add tag suggestion dropdown for NoteAdd and TaskAdd views

Implement a tag suggestion dropdown in both NoteAdd and TaskAdd components.
When the tag input is focused, available tags are fetched from the API
(GET /rest/home/tasks/tags) and displayed in a filterable dropdown list.
Users can select an existing tag or type a custom one.

Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>

* chore: fix lint issue

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Ricardo Campos <RMCampos@users.noreply.github.com>
This commit is contained in:
2026-02-10 17:35:26 -03:00
committed by GitHub
co-authored by claude[bot] <41898282+claude[bot]@users.noreply.github.com> Ricardo Campos
parent d734845f75
commit e3f3259179
3 changed files with 161 additions and 29 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ else
fi
echo "3/3 - Tests started..."
npm run test
npm run test:no-watch
if [ $? -eq 1 ]; then
echo "Issues when running test. Please review.."
exit 1
+80 -14
View File
@@ -1,11 +1,14 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
Card,
Col,
Container,
Form,
InputGroup,
ListGroup,
Row
} from 'react-bootstrap';
import { Hash } from 'react-bootstrap-icons';
import { useNavigate, useParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { NoteResponse } from '../../types/NoteResponse';
@@ -32,11 +35,24 @@ function NoteAdd(): React.ReactNode {
const [noteContent, setNoteContent] = useState<string>('');
const [noteUrl, setNoteUrl] = useState<string>('');
const [noteTag, setNoteTag] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
const [action, setAction] = useState<NoteAction>('add');
const [showPreviewMd, setShowPreviewMd] = useState<boolean>(false);
const { i18n, t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const tagContainerRef = useRef<HTMLDivElement>(null);
const loadTags = async (): Promise<void> => {
try {
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
setTags(response);
}
catch (e) {
handleError(e);
}
};
/**
* Handles errors by setting the error message and form invalid state.
@@ -222,8 +238,20 @@ function NoteAdd(): React.ReactNode {
const handleCloseModal = (): void => setShowPreviewMd(false);
useEffect(() => {
loadTags();
checkEditUrl();
checkCloneUrl();
const handleClickOutside = (event: MouseEvent): void => {
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
setShowTagDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
@@ -284,19 +312,57 @@ function NoteAdd(): React.ReactNode {
/>
</Col>
<Col xs={12} xl={3}>
{/* Tag */}
<FormInput
labelText="Tag"
iconName="Hash"
required={false}
type="text"
name="tag"
placeholder="my-tag (Optional)"
value={noteTag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteTag(e.target.value);
}}
/>
{/* Tag with suggestion dropdown */}
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
<Form.Label>Tag</Form.Label>
<InputGroup className="mb-3">
<InputGroup.Text>
<Hash />
</InputGroup.Text>
<Form.Control
type="text"
name="tag"
placeholder="my-tag (Optional)"
value={noteTag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteTag(e.target.value);
setShowTagDropdown(true);
}}
onFocus={() => setShowTagDropdown(true)}
autoComplete="off"
/>
</InputGroup>
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(noteTag.toLowerCase())).length > 0 && (
<ListGroup
style={{
position: 'absolute',
zIndex: 1000,
width: '100%',
maxHeight: '200px',
overflowY: 'auto',
top: '100%',
left: 0
}}
>
{tags
.filter(t => t.toLowerCase().includes(noteTag.toLowerCase()))
.map(t => (
<ListGroup.Item
key={t}
action
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault();
setNoteTag(t);
setShowTagDropdown(false);
}}
>
#
{t}
</ListGroup.Item>
))}
</ListGroup>
)}
</Form.Group>
</Col>
</Row>
+80 -14
View File
@@ -1,11 +1,14 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
Card,
Col,
Container,
Form,
InputGroup,
ListGroup,
Row
} from 'react-bootstrap';
import { Hash } from 'react-bootstrap-icons';
import { useNavigate, useParams } from 'react-router';
import TaskNoteRequest from '../../types/TaskNoteRequest';
import { TaskResponse } from '../../types/TaskResponse';
@@ -35,9 +38,22 @@ function TaskAdd(): React.ReactNode {
const [dueDate, setDueDate] = useState<Date | null>(null);
const [highPriority, setHighPriority] = useState<boolean>(false);
const [tag, setTag] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
const { i18n, t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const tagContainerRef = useRef<HTMLDivElement>(null);
const loadTags = async (): Promise<void> => {
try {
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
setTags(response);
}
catch (e) {
handleError(e);
}
};
/**
* Handles errors by setting the error message and form invalid state.
@@ -190,7 +206,19 @@ function TaskAdd(): React.ReactNode {
};
useEffect(() => {
loadTags();
checkEditUrl();
const handleClickOutside = (event: MouseEvent): void => {
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
setShowTagDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
@@ -267,19 +295,57 @@ function TaskAdd(): React.ReactNode {
/>
</Col>
<Col xs={12} sm={6} xxl={3}>
{/* Tag */}
<FormInput
labelText="Tag"
iconName="Hash"
required={false}
type="text"
name="tag"
placeholder="my-tag (Optional)"
value={tag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTag(e.target.value);
}}
/>
{/* Tag with suggestion dropdown */}
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
<Form.Label>Tag</Form.Label>
<InputGroup className="mb-3">
<InputGroup.Text>
<Hash />
</InputGroup.Text>
<Form.Control
type="text"
name="tag"
placeholder="my-tag (Optional)"
value={tag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTag(e.target.value);
setShowTagDropdown(true);
}}
onFocus={() => setShowTagDropdown(true)}
autoComplete="off"
/>
</InputGroup>
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(tag.toLowerCase())).length > 0 && (
<ListGroup
style={{
position: 'absolute',
zIndex: 1000,
width: '100%',
maxHeight: '200px',
overflowY: 'auto',
top: '100%',
left: 0
}}
>
{tags
.filter(t => t.toLowerCase().includes(tag.toLowerCase()))
.map(t => (
<ListGroup.Item
key={t}
action
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault();
setTag(t);
setShowTagDropdown(false);
}}
>
#
{t}
</ListGroup.Item>
))}
</ListGroup>
)}
</Form.Group>
</Col>
</Row>