tududi/app/frontend/Tasks.tsx
Chris Veleris dfcb97a355 Move to React
Add .gitignore

Removed node_modules from previous commit

Fix task modes

Fix task modes

Fix task modes

Remove node_modules

Update basic task modal

Add notes functionality

Improve UI

Setup views

Add scopes

Fix projects layout

Restructure

Fix rest of the UI issues

Cleanup old views

Add .env to .gitignore
2024-10-25 21:03:43 +03:00

321 lines
11 KiB
TypeScript

import React, { useEffect, useState, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import TaskList from "./components/Task/TaskList";
import NewTask from "./NewTask";
import { Task } from "./entities/Task";
import { Project } from "./entities/Project";
import { getTitleAndIcon } from "./components/Task/getTitleAndIcon";
import { getDescription } from "./components/Task/getDescription";
import { TagIcon, XMarkIcon } from "@heroicons/react/24/solid"; // Import X icon for removing tag
// Helper function to capitalize the first letter of a string
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
const Tasks: React.FC = () => {
const [tasks, setTasks] = useState<Task[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
const [orderBy, setOrderBy] = useState<string>("due_date:asc"); // State for sorting
const dropdownRef = useRef<HTMLDivElement>(null); // Reference to the dropdown
const location = useLocation();
const navigate = useNavigate();
const query = new URLSearchParams(location.search);
const { title: stateTitle, icon: stateIcon } = location.state || {};
const { title, icon } =
stateTitle && stateIcon
? { title: stateTitle, icon: stateIcon }
: getTitleAndIcon(query, projects);
// Extract tag from query params
const tag = query.get("tag");
// Load orderBy from localStorage or use default
useEffect(() => {
const savedOrderBy = localStorage.getItem("order_by") || "due_date:asc";
setOrderBy(savedOrderBy);
const params = new URLSearchParams(location.search);
if (!params.get("order_by")) {
params.set("order_by", savedOrderBy); // Set the default to URL if not present
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
}
}, [location, navigate]);
// Close dropdown if clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [dropdownOpen]);
// Fetch data when location changes
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
// Fetch tasks with the selected tag if present
const tagId = query.get("tag");
const [tasksResponse, projectsResponse] = await Promise.all([
fetch(`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ""}`),
fetch("/api/projects"),
]);
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
setTasks(tasksData || []);
} else {
throw new Error("Failed to fetch tasks.");
}
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
setProjects(projectsData?.projects || []);
} else {
throw new Error("Failed to fetch projects.");
}
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
}
};
fetchData();
}, [location]);
// Function to remove the tag from the URL
const handleRemoveTag = () => {
const params = new URLSearchParams(location.search);
params.delete("tag"); // Remove tag from query params
navigate({
pathname: location.pathname,
search: `?${params.toString()}`, // Update the URL without the tag parameter
});
};
// Function to create a new task
const handleTaskCreate = async (taskData: Partial<Task>) => {
try {
const response = await fetch("/api/task", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(taskData),
});
if (response.ok) {
const newTask = await response.json();
setTasks((prevTasks) => [newTask, ...prevTasks]);
} else {
const errorData = await response.json();
console.error("Failed to create task:", errorData.error);
setError("Failed to create task.");
}
} catch (error) {
console.error("Error creating task:", error);
setError("Error creating task.");
}
};
// Function to update an existing task
const handleTaskUpdate = async (updatedTask: Task) => {
try {
const response = await fetch(`/api/task/${updatedTask.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTask),
});
if (response.ok) {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
}
} catch (error) {
console.error("Error updating task:", error);
setError("Error updating task.");
}
};
// Function to delete a task
const handleTaskDelete = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}`, {
method: "DELETE",
});
if (response.ok) {
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
} else {
const errorData = await response.json();
console.error("Failed to delete task:", errorData.error);
setError("Failed to delete task.");
}
} catch (error) {
console.error("Error deleting task:", error);
setError("Error deleting task.");
}
};
// Handle sorting changes
const handleSortChange = (order: string) => {
setOrderBy(order);
localStorage.setItem("order_by", order); // Save the selected order to localStorage
const params = new URLSearchParams(location.search);
params.set("order_by", order); // Update or add the order_by param
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
setDropdownOpen(false); // Close dropdown on selection
};
// Get the description for the current task view
const description = getDescription(query, projects);
return (
<div className="flex justify-center px-4">
{" "}
{/* Center the content with padding */}
<div className="w-full max-w-4xl">
{" "}
{/* Limit the width to 3xl (48rem) */}
{/* Title and Icon */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<i className={`bi ${icon} text-xl mr-2`}></i>
<h2 className="text-2xl font-light">
{title}
</h2>
{/* If tag exists, display it as a styled button with an X to remove */}
{tag && (
<div className="ml-4 flex items-center space-x-2">
<button
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={handleRemoveTag}
>
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
<span className="text-xs text-gray-700 dark:text-gray-300">{capitalize(tag)}</span>
<XMarkIcon className="h-4 w-4 text-gray-500 dark:text-gray-300 hover:text-red-500" />
</button>
</div>
)}
</div>
{/* Sort Dropdown */}
<div className="relative inline-block text-left" ref={dropdownRef}>
<div>
<button
type="button"
className="inline-flex justify-center w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
id="menu-button"
aria-expanded="true"
aria-haspopup="true"
onClick={() => setDropdownOpen(!dropdownOpen)}
>
<i className="bi bi-sort-alpha-down me-2"></i>{" "}
{capitalize(orderBy.split(":")[0].replace("_", " "))}
<svg
className="-mr-1 ml-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.292 7.707a1 1 0 011.414 0L10 11.414l3.293-3.707a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
{dropdownOpen && (
<div
className="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10"
role="menu"
aria-orientation="vertical"
aria-labelledby="menu-button"
>
<div className="py-1" role="none">
{[
"due_date:asc",
"name:asc",
"priority:desc",
"status:desc",
"created_at:desc",
].map((order) => (
<button
key={order}
onClick={() => handleSortChange(order)}
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
>
{capitalize(order.split(":")[0].replace("_", " "))}
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Description */}
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
{loading ? (
<p>Loading...</p>
) : error ? (
<p className="text-red-500">{error}</p>
) : (
<>
{/* New Task Form */}
<NewTask
onTaskCreate={(taskName: string) =>
handleTaskCreate({ name: taskName, status: "not_started" })
}
/>
{/* Task List */}
{tasks.length > 0 ? (
<TaskList
tasks={tasks}
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={projects}
/>
) : (
<p className="text-gray-500 text-center mt-4">
No tasks available.
</p>
)}
</>
)}
</div>
</div>
);
};
export default Tasks;