Files
tasknote/client/src/views/NoteAdd/index.tsx
T
rmcampos 16621cd73b
CI / Build Backend (pull_request) Successful in 58s
CI / Build Frontend (pull_request) Successful in 7m11s
feat: add closing and saving button to modal. Closes #15
2026-07-22 11:44:06 -03:00

578 lines
18 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
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';
import api from '../../api-service/api';
import ApiConfig from '../../api-service/apiConfig';
import { translateServerResponse } from '../../utils/TranslatorUtils';
import FormInput from '../../components/FormInput';
import ModalMarkdown from '../../components/ModalMarkdown';
import AlertError from '../../components/AlertError';
import ContentHeader from '../../components/ContentHeader';
type NoteAction = 'add' | 'edit';
interface NoteDraft {
title: string;
content: string;
noteUrl: string;
tags: string[];
}
/**
* NoteAdd component for adding and editing notes.
*
* @returns {React.ReactNode} The rendered NoteAdd component.
*/
function NoteAdd(): React.ReactNode {
const [validated, setValidated] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const [noteId, setNoteId] = useState<number>(0);
const [noteTitle, setNoteTitle] = useState<string>('');
const [noteContent, setNoteContent] = useState<string>('');
const [noteUrl, setNoteUrl] = useState<string>('');
const [currentTag, setCurrentTag] = useState<string>('');
const [selectedTags, setSelectedTags] = 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 [draftBanner, setDraftBanner] = useState<boolean>(false);
const { i18n, t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const tagContainerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasUserEdited = useRef<boolean>(false);
const draftKey = params?.id ? `draft:note:edit:${params.id}` : 'draft:note:new';
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.
*
* @param {unknown} e - The error to handle.
*/
const handleError = (e: unknown): void => {
if (typeof e === 'string') {
setErrorMessage(translateServerResponse(e, i18n.language));
}
else if (e instanceof Error) {
setErrorMessage(translateServerResponse(e.message, i18n.language));
}
};
/**
* Adds a new note.
*
* @param {NoteResponse} payload - The note data to add.
* @returns {Promise<boolean>} True if the note was added successfully, false otherwise.
*/
const addNote = async (payload: NoteResponse): Promise<boolean> => {
try {
await api.postJSON(ApiConfig.notesUrl, payload);
return true;
}
catch (e) {
handleError(e);
}
return false;
};
/**
* Submits the edited note.
*
* @param {NoteResponse} payload - The note data to edit.
* @returns {Promise<boolean>} True if the note was edited successfully, false otherwise.
*/
const submitEditNote = async (payload: NoteResponse): Promise<boolean> => {
try {
await api.patchJSON(`${ApiConfig.notesUrl}/${payload.id}`, payload);
return true;
}
catch (e) {
handleError(e);
}
return false;
};
/**
* Resets the input fields to their default values.
*/
const resetInputs = () => {
setNoteId(0);
setNoteTitle('');
setNoteUrl('');
setNoteContent('');
setCurrentTag('');
setSelectedTags([]);
setAction('add');
setValidated(false);
};
const saveDraft = (title: string, content: string, noteUrl: string, draftTags: string[]): void => {
if (!hasUserEdited.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const draft: NoteDraft = { title, content, noteUrl, tags: draftTags };
localStorage.setItem(draftKey, JSON.stringify(draft));
}, 1500);
};
const clearDraft = (): void => {
if (debounceRef.current) clearTimeout(debounceRef.current);
localStorage.removeItem(draftKey);
};
const applyDraft = (): void => {
const raw = localStorage.getItem(draftKey);
if (!raw) return;
try {
const draft: NoteDraft = JSON.parse(raw);
setNoteTitle(draft.title ?? '');
setNoteContent(draft.content ?? '');
setNoteUrl(draft.noteUrl ?? '');
setSelectedTags(draft.tags ?? []);
setDraftBanner(true);
}
catch {
localStorage.removeItem(draftKey);
}
};
const handleDiscardDraft = async (): Promise<void> => {
setDraftBanner(false);
if (params?.id) {
try {
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
setNoteFromServer(noteToEdit);
}
catch (e) {
handleError(e);
}
finally {
clearDraft();
}
}
else {
clearDraft();
resetInputs();
}
};
const addTag = (tagName: string): void => {
const normalized = tagName.trim().toLowerCase();
let newTags = [...selectedTags];
if (normalized && !selectedTags.includes(normalized)) {
newTags = [...selectedTags, normalized];
setSelectedTags(newTags);
}
hasUserEdited.current = true;
saveDraft(noteTitle, noteContent, noteUrl, newTags);
setCurrentTag('');
setShowTagDropdown(false);
};
const removeTag = (tagToRemove: string): void => {
const newTags = selectedTags.filter(t => t !== tagToRemove);
setSelectedTags(newTags);
hasUserEdited.current = true;
saveDraft(noteTitle, noteContent, noteUrl, newTags);
};
/**
* Saves the note, either adding a new one or editing an existing one.
*
* @returns {Promise<boolean>} True if the note was saved successfully, false otherwise.
*/
const saveNote = async (): Promise<boolean> => {
setValidated(true);
if (!noteTitle.trim() || !noteContent.trim()) {
setErrorMessage(translateServerResponse('Please fill in all the fields', i18n.language));
return false;
}
const finalTags = [...selectedTags];
if (currentTag.trim()) {
const normalized = currentTag.trim().toLowerCase();
if (!finalTags.includes(normalized)) {
finalTags.push(normalized);
}
}
const payload: NoteResponse = {
id: action === 'edit' ? noteId : 0,
title: noteTitle,
description: noteContent,
url: noteUrl,
tags: finalTags,
lastUpdate: '',
shared: false,
shareToken: null
};
const saved = action === 'add'
? await addNote(payload)
: await submitEditNote(payload);
if (saved) {
clearDraft();
resetInputs();
navigate('/home');
}
return saved;
};
/**
* Handles the form submission.
*
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
*/
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
setValidated(true);
const form = event.currentTarget;
if (!form.checkValidity()) {
setErrorMessage(translateServerResponse('Please fill in all the fields', i18n.language));
return;
}
await saveNote();
};
/**
* Checks if the URL is for editing a note and loads the note data if it is.
*/
const checkEditUrl = async (): Promise<void> => {
if (params?.id) {
try {
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
setNoteFromServer(noteToEdit);
setAction('edit');
applyDraft();
}
catch (e) {
handleError(e);
}
}
};
const checkCloneUrl = async (): Promise<void> => {
const { search } = window.location;
if (!search || !search.startsWith('?') || !search.includes('cloneFrom=')) {
return;
}
const index = search.indexOf('=');
if (search.length - index > 4) {
return;
}
const idToClone = search.substring(index + 1);
if (idToClone && !isNaN(parseInt(idToClone))) {
try {
const noteToClone: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${idToClone}`);
setNoteFromServer(noteToClone);
}
catch (e) {
handleError(e);
}
}
};
const setNoteFromServer = (noteData: NoteResponse) => {
setNoteId(noteData.id);
setNoteTitle(noteData.title);
if (noteData.url) {
setNoteUrl(noteData.url);
}
if (noteData.tags) {
setSelectedTags(noteData.tags);
}
setNoteContent(noteData.description);
};
/**
* Display the Markdown text in Markdown format on a modal.
*
* @param {React.MouseEvent<Element, MouseEvent>} e The mouse click event.
*/
const previewMarkdown = (e: React.MouseEvent<Element, MouseEvent>): void => {
e.preventDefault();
e.stopPropagation();
setShowPreviewMd(noteContent.length > 0);
};
/**
* Closes the Markdown preview modal.
*/
const handleCloseModal = (): void => setShowPreviewMd(false);
useEffect(() => {
loadTags();
checkEditUrl();
checkCloneUrl();
if (!params?.id && !window.location.search.includes('cloneFrom=')) {
applyDraft();
}
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);
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
return (
<Container fluid>
<ContentHeader
h1TextRegular="Add"
h1TextBold="Note"
subtitle="Save your notes in plain text or Markdown format"
h2BlackText="Create, Filter, and Easily Find"
h2GreenText="Them"
/>
<Row className="main-margin">
<Col xs={12}>
<Card>
<Card.Body>
<Card.Title>{t('note_form_title')}</Card.Title>
<AlertError
errorMessage={errorMessage}
onClose={() => setErrorMessage('')}
/>
{draftBanner && (
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
Draft restored from a previous session.
{' '}
<Alert.Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
void handleDiscardDraft();
}}
>
Discard draft
</Alert.Link>
</Alert>
)}
<Form
noValidate
validated={validated}
onSubmit={handleSubmit}
autoComplete="off"
>
{/* Note title */}
<FormInput
labelText={t('note_form_title_label')}
iconName="JournalCheck"
required={true}
type="text"
name="note_title"
placeholder={t('note_form_title_placeholder')}
value={noteTitle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteTitle(e.target.value);
hasUserEdited.current = true;
saveDraft(e.target.value, noteContent, noteUrl, selectedTags);
}}
/>
<Row>
<Col xs={12} xl={9}>
{/* Note URL */}
<FormInput
labelText={t('task_form_url_label')}
iconName="At"
required={false}
type="text"
name="url"
placeholder={t('task_form_url_placeholder')}
value={noteUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteUrl(e.target.value);
hasUserEdited.current = true;
saveDraft(noteTitle, noteContent, e.target.value, selectedTags);
}}
/>
</Col>
<Col xs={12} xl={3}>
{/* Tag with suggestion dropdown */}
<Form.Group className="mb-3" ref={tagContainerRef} style={{ position: 'relative' }}>
<Form.Label>Tags</Form.Label>
<InputGroup className="mb-3">
<InputGroup.Text>
<Hash />
</InputGroup.Text>
<Form.Control
type="text"
name="tag"
placeholder="my-tag (Optional)"
value={currentTag}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setCurrentTag(e.target.value);
setShowTagDropdown(true);
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && currentTag.trim()) {
e.preventDefault();
addTag(currentTag);
}
}}
onFocus={() => setShowTagDropdown(true)}
autoComplete="off"
/>
</InputGroup>
<div className="mb-2 d-flex flex-wrap gap-1">
{selectedTags.map(t => (
<Badge
key={t}
bg="warning"
text="dark"
className="p-2"
style={{ cursor: 'pointer' }}
onClick={() => removeTag(t)}
>
#
{t}
{' '}
&times;
</Badge>
))}
</div>
{showTagDropdown && tags.filter(t => t.toLowerCase().includes(currentTag.toLowerCase())).length > 0 && (
<ListGroup
style={{
position: 'absolute',
zIndex: 1000,
width: '100%',
maxHeight: '200px',
overflowY: 'auto',
left: 0
}}
>
{tags
.filter(t => t.toLowerCase().includes(currentTag.toLowerCase()))
.map(t => (
<ListGroup.Item
key={t}
action
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault();
addTag(t);
}}
>
#
{t}
</ListGroup.Item>
))}
</ListGroup>
)}
</Form.Group>
</Col>
</Row>
<Form.Group controlId="form_noteDescription">
<Form.Label>
{t('note_form_content_label')}
<small>
<a href="#" onClick={previewMarkdown}>
{' '}
Preview Markdown
</a>
</small>
</Form.Label>
<Form.Control
className="font-size-14"
as="textarea"
required={true}
size="lg"
rows={15}
name="note_description"
aria-describedby="noteDescriptionHelper"
placeholder={t('note_form_content_placeholder')}
value={noteContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNoteContent(e.target.value);
hasUserEdited.current = true;
saveDraft(noteTitle, e.target.value, noteUrl, selectedTags);
}}
data-testid="note-content-input-area"
/>
</Form.Group>
<button
type="submit"
className="home-new-item task-note-btn mt-3"
>
{t('note_form_submit')}
</button>
<button
type="button"
className="ms-2 home-new-item-secondary task-note-btn"
onClick={() => {
clearDraft();
navigate('/home');
}}
>
Cancel
</button>
</Form>
</Card.Body>
</Card>
</Col>
</Row>
<ModalMarkdown
show={showPreviewMd}
onHide={handleCloseModal}
title={noteTitle}
markdownText={noteContent}
onSave={saveNote}
saveButtonLabel={t('note_form_submit')}
/>
</Container>
);
}
export default NoteAdd;