Skip to content

Commit

Permalink
Add project tags
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisvel committed Nov 21, 2024
1 parent 9047ff4 commit 29c50da
Show file tree
Hide file tree
Showing 9 changed files with 176 additions and 55 deletions.
17 changes: 8 additions & 9 deletions app/frontend/components/Project/ProjectDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useEffect, useState } from "react";
import { useParams, useLocation, useNavigate, Link } from "react-router-dom";
import { useParams, useNavigate, Link } from "react-router-dom";
import {
PencilSquareIcon,
TrashIcon,
FolderIcon,
Squares2X2Icon,
} from "@heroicons/react/24/solid";
} from "@heroicons/react/24/outline"; // Updated import
import TaskList from "../Task/TaskList";
import ProjectModal from "../Project/ProjectModal";
import ConfirmDialog from "../Shared/ConfirmDialog";
Expand All @@ -16,7 +17,6 @@ import { Task } from "../../entities/Task";
const ProjectDetails: React.FC = () => {
const { updateTask, deleteTask, updateProject, deleteProject } = useDataContext();
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();

const { areas } = useDataContext();
Expand All @@ -28,9 +28,8 @@ const ProjectDetails: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);

const { title: stateTitle, icon: stateIcon } = location.state || {};
const projectTitle = stateTitle || project?.name || "Project";
const projectIcon = stateIcon;
// Removed location.state related code
const projectTitle = project?.name || "Project";

const [isCompletedOpen, setIsCompletedOpen] = useState(false);

Expand Down Expand Up @@ -129,8 +128,8 @@ const ProjectDetails: React.FC = () => {
if (!updatedProject) return;

try {
await updateProject(updatedProject.id!, updatedProject);
setProject(updatedProject);
const savedProject = await updateProject(updatedProject.id!, updatedProject);
setProject(savedProject);
setIsModalOpen(false);
} catch (err) {
console.error("Error saving project:", err);
Expand Down Expand Up @@ -179,7 +178,7 @@ const ProjectDetails: React.FC = () => {
{/* Project Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<i className={`${projectIcon} text-xl mr-2`}></i>
<FolderIcon className="h-6 w-6 text-gray-500 mr-2" />
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
{projectTitle}
</h2>
Expand Down
148 changes: 114 additions & 34 deletions app/frontend/components/Project/ProjectModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef } from 'react';
import { Area } from '../../entities/Area';
import { Project } from '../../entities/Project';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { useToast } from '../Shared/ToastContext';
import { XMarkIcon } from '@heroicons/react/24/outline';
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Area } from "../../entities/Area";
import { Project } from "../../entities/Project";
import ConfirmDialog from "../Shared/ConfirmDialog";
import { useToast } from "../Shared/ToastContext";
import TagInput from "../Tag/TagInput";
import useFetchTags from "../../hooks/useFetchTags";

interface ProjectModalProps {
isOpen: boolean;
Expand All @@ -22,34 +23,52 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
project,
areas,
}) => {
// Initialize form data with existing project or default values
const [formData, setFormData] = useState<Project>(
project || {
name: '',
description: '',
name: "",
description: "",
area_id: null,
active: true,
tags: [],
}
);

// State to manage tags as an array of tag names
const [tags, setTags] = useState<string[]>(project?.tags?.map(tag => tag.name) || []);

// Fetch available tags from the backend
const { tags: availableTags, isLoading: isTagsLoading, isError: isTagsError } = useFetchTags();

// Refs and state for handling modal animations and confirmations
const modalRef = useRef<HTMLDivElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);

// Toast notifications for user feedback
const { showSuccessToast, showErrorToast } = useToast();

// Update form data and tags when the `project` prop changes
useEffect(() => {
if (project) {
setFormData(project);
setFormData({
...project,
tags: project.tags || [],
});
setTags(project.tags?.map(tag => tag.name) || []);
} else {
setFormData({
name: '',
description: '',
name: "",
description: "",
area_id: null,
active: true,
tags: [],
});
setTags([]);
}
}, [project]);

// Handle clicks outside the modal to close it
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
Expand All @@ -61,99 +80,145 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
};

if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);

// Handle pressing the Escape key to close the modal
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (event.key === "Escape") {
handleClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.addEventListener("keydown", handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);

// Handle changes in form inputs
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const target = e.target;
const { name, type } = target;
const { name, type, value } = target;

if (type === 'checkbox') {
const checked = (target as HTMLInputElement).checked;
setFormData((prev) => ({
...prev,
[name]: checked,
}));
if (type === "checkbox") {
// Type Narrowing: Ensure target is HTMLInputElement before accessing 'checked'
if (target instanceof HTMLInputElement) {
const checked = target.checked;
setFormData((prev) => ({
...prev,
[name]: checked,
}));
}
} else {
const value = target.value;
setFormData((prev) => ({
...prev,
[name]: value,
}));
}
};

// Handle changes in tags using the TagInput component
const handleTagsChange = useCallback((newTags: string[]) => {
setTags(newTags);
setFormData((prev) => ({
...prev,
tags: newTags.map((name) => ({ name })),
}));
}, []);

// Handle form submission
const handleSubmit = () => {
onSave(formData);
onSave({ ...formData, tags: tags.map(name => ({ name })) });
showSuccessToast(
project ? 'Project updated successfully!' : 'Project created successfully!'
project
? "Project updated successfully!"
: "Project created successfully!"
);
handleClose();
};

// Handle delete button click to show confirmation dialog
const handleDeleteClick = () => {
setShowConfirmDialog(true);
};

// Confirm deletion of the project
const handleDeleteConfirm = () => {
if (project && project.id && onDelete) {
onDelete(project.id);
showSuccessToast('Project deleted successfully!');
showSuccessToast("Project deleted successfully!");
setShowConfirmDialog(false);
handleClose();
}
};

// Handle closing the modal with animation
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 300);
}, 300); // Duration should match the CSS transition
};

// Render nothing if the modal is not open
if (!isOpen) return null;

// Show loading state while tags are being fetched
if (isTagsLoading) {
return (
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
Loading tags...
</div>
</div>
);
}

// Show error state if tags failed to load
if (isTagsError) {
return (
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
Error loading tags.
</div>
</div>
);
}

return (
<>
{/* Modal Overlay */}
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
isClosing ? 'opacity-0' : 'opacity-100'
isClosing ? "opacity-0" : "opacity-100"
}`}
>
{/* Modal Content */}
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl overflow-hidden transform transition-transform duration-300 ${
isClosing ? 'scale-95' : 'scale-100'
isClosing ? "scale-95" : "scale-100"
} h-screen sm:h-auto flex flex-col`}
style={{
maxHeight: 'calc(100vh - 4rem)',
maxHeight: "calc(100vh - 4rem)", // Prevent modal from exceeding viewport height
}}
>
{/* Form */}
<form className="flex flex-col flex-1">
<fieldset className="flex flex-col flex-1">
{/* Form Fields */}
<div className="p-4 space-y-3 flex-1 text-sm overflow-y-auto">
{/* Project Name */}
<div className="py-4">
Expand All @@ -178,13 +243,27 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
id="projectDescription"
name="description"
rows={4}
value={formData.description || ''}
value={formData.description || ""}
onChange={handleChange}
className="block w-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
placeholder="Enter project description (optional)"
></textarea>
</div>

{/* Tags */}
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Tags
</label>
<div className="w-full">
<TagInput
onTagsChange={handleTagsChange}
initialTags={tags}
availableTags={availableTags}
/>
</div>
</div>

{/* Area */}
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Expand All @@ -193,7 +272,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<select
id="projectArea"
name="area_id"
value={formData.area_id || ''}
value={formData.area_id || ""}
onChange={handleChange}
className="block w-full rounded-md shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
>
Expand Down Expand Up @@ -248,14 +327,15 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out"
>
{project ? 'Update Project' : 'Create Project'}
{project ? "Update Project" : "Create Project"}
</button>
</div>
</fieldset>
</form>
</div>
</div>

{/* Confirmation Dialog for Deletion */}
{showConfirmDialog && (
<ConfirmDialog
title="Delete Project"
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/contexts/DataContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface DataContextProps {
updateArea: (areaId: number, areaData: any) => Promise<void>;
deleteArea: (areaId: number) => Promise<void>;
createProject: (projectData: any) => Promise<Project>;
updateProject: (projectId: number, projectData: any) => Promise<void>;
updateProject: (projectId: number, projectData: any) => Promise<Project>;
deleteProject: (projectId: number) => Promise<void>;
createTag: (tagData: any) => Promise<void>;
updateTag: (tagId: number, tagData: any) => Promise<void>;
Expand Down
4 changes: 3 additions & 1 deletion app/frontend/entities/Project.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Area } from "./Area";
import { Tag } from "./Tag";

export interface Project {
id?: number;
Expand All @@ -7,5 +8,6 @@ export interface Project {
active: boolean;
pin_to_sidebar?: boolean;
area?: Area;
area_id?: number | null;
area_id?: number | null;
tags?: Tag[];
}
1 change: 1 addition & 0 deletions app/frontend/hooks/useManageProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const useManageProjects = () => {
const updatedProject: Project = await response.json();
mutate('/api/projects', (current: Project[] = []) =>
current.map((project) => (project.id === projectId ? updatedProject : project)), false);
return updatedProject;
} catch (error) {
console.error('Error updating project:', error);
throw error;
Expand Down
1 change: 1 addition & 0 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class Project < ActiveRecord::Base
belongs_to :area, optional: true
has_many :tasks, dependent: :destroy
has_many :notes, dependent: :destroy
has_and_belongs_to_many :tags

scope :with_incomplete_tasks, -> { joins(:tasks).where.not(tasks: { status: Task.statuses[:done] }).distinct }
scope :with_complete_tasks, -> { joins(:tasks).where(tasks: { status: Task.statuses[:done] }).distinct }
Expand Down
1 change: 1 addition & 0 deletions app/models/tag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class Tag < ActiveRecord::Base
belongs_to :user
has_and_belongs_to_many :tasks
has_and_belongs_to_many :notes
has_and_belongs_to_many :projects

validates :name, presence: true, uniqueness: { scope: :user_id }
end
Loading

0 comments on commit 29c50da

Please sign in to comment.