tududi/frontend/components/Shared/ProjectDropdown.tsx
Chris 0440f46645
fix(ui): Auto-focus project search field when opened (Issue #992) (#1038)
* fix(oidc): Add logging to help debug missing OIDC button (Issue #1036)

When OIDC is configured but the login button doesn't appear, users
had no way to debug what was wrong. This adds comprehensive logging:

- Log when OIDC is disabled with helpful message
- Log when providers are successfully loaded
- Warn when numbered providers are skipped due to missing fields
- Error when single provider config is incomplete with specific missing fields
- Warn when OIDC is enabled but no valid providers found

This helps users quickly identify configuration issues like:
- Typos in environment variable names
- Missing required fields (issuer, clientId, clientSecret)
- Incorrect OIDC_ENABLED value

Fixes #1036

* fix(ui): Auto-focus project search field when opened (Issue #992)

When clicking on the project field in a task to add or change the project,
the search input field now automatically receives focus, eliminating the
need to manually click on it.

This improves the user experience by reducing friction in the project
assignment workflow.

Fixes #992
2026-04-18 00:29:27 +03:00

306 lines
14 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Project } from '../../entities/Project';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
interface ProjectDropdownProps {
projectName: string;
onProjectSearch: (query: string) => void;
dropdownOpen: boolean;
filteredProjects: Project[];
onProjectSelection: (project: Project) => void;
onCreateProject: (name: string) => void | Promise<void>;
isCreatingProject: boolean;
onShowAllProjects: () => void;
allProjects: Project[];
placeholder?: string;
disabled?: boolean;
selectedProject?: Project | null;
onClearProject?: () => void;
}
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
projectName,
onProjectSearch,
dropdownOpen,
filteredProjects,
onProjectSelection,
onCreateProject,
isCreatingProject,
onShowAllProjects,
allProjects,
placeholder,
disabled = false,
selectedProject,
onClearProject,
}) => {
const { t } = useTranslation();
const dropdownRef = useRef<HTMLDivElement>(null);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
// Scroll to dropdown when it opens, keeping input visible
useEffect(() => {
if (dropdownOpen && dropdownRef.current) {
// Small delay to ensure the dropdown is rendered
setTimeout(() => {
const dropdownElement = dropdownRef.current;
if (dropdownElement) {
// Find the input field to keep it visible
const inputElement =
dropdownElement.parentElement?.querySelector('input');
// Find the appropriate scroll container (modal or window)
const modalScrollContainer = dropdownElement.closest(
'.absolute.inset-0.overflow-y-auto'
);
if (modalScrollContainer && inputElement) {
// We're inside a modal - scroll the modal container
const containerRect =
modalScrollContainer.getBoundingClientRect();
const dropdownRect =
dropdownElement.getBoundingClientRect();
const inputRect = inputElement.getBoundingClientRect();
// Only scroll if dropdown extends below the visible area
if (dropdownRect.bottom > containerRect.bottom - 50) {
// Calculate very conservative scroll - prioritize keeping input visible
const inputDistanceFromTop =
inputRect.top - containerRect.top;
const minInputVisible = 60; // Keep at least 60px of input visible
// Only scroll if we can maintain input visibility
if (inputDistanceFromTop > minInputVisible) {
const maxAllowedScroll =
inputDistanceFromTop - minInputVisible;
const neededScroll =
dropdownRect.bottom -
containerRect.bottom +
30;
const scrollAmount = Math.min(
maxAllowedScroll,
neededScroll
);
if (scrollAmount > 0) {
modalScrollContainer.scrollBy({
top: scrollAmount,
behavior: 'smooth',
});
}
}
}
} else if (inputElement) {
// We're not in a modal - scroll the window
const dropdownRect =
dropdownElement.getBoundingClientRect();
const inputRect = inputElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Only scroll if dropdown extends below the viewport
if (dropdownRect.bottom > viewportHeight - 50) {
// Calculate very conservative scroll - prioritize keeping input visible
const inputDistanceFromTop = inputRect.top;
const minInputVisible = 60; // Keep at least 60px of input visible
// Only scroll if we can maintain input visibility
if (inputDistanceFromTop > minInputVisible) {
const maxAllowedScroll =
inputDistanceFromTop - minInputVisible;
const neededScroll =
dropdownRect.bottom - viewportHeight + 30;
const scrollAmount = Math.min(
maxAllowedScroll,
neededScroll
);
if (scrollAmount > 0) {
window.scrollBy({
top: scrollAmount,
behavior: 'smooth',
});
}
}
}
}
}
}, 200);
}
}, [dropdownOpen]);
// Reset highlighted index when dropdown state changes or projects change
useEffect(() => {
setHighlightedIndex(-1);
}, [dropdownOpen, filteredProjects, allProjects]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!dropdownOpen) return;
const projectsToShow = projectName ? filteredProjects : allProjects;
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex((prev) =>
prev < projectsToShow.length - 1 ? prev + 1 : prev
);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (event.key === 'Enter') {
event.preventDefault();
// Check for exact match first (case-insensitive)
const exactMatch = projectsToShow.find(
(project) =>
project.name.toLowerCase() ===
projectName.trim().toLowerCase()
);
if (exactMatch) {
// Exact match found - auto-select it
onProjectSelection(exactMatch);
} else if (
highlightedIndex >= 0 &&
highlightedIndex < projectsToShow.length
) {
// Select the highlighted project (after using arrow keys)
onProjectSelection(projectsToShow[highlightedIndex]);
} else if (projectName.trim() && projectsToShow.length === 0) {
// No matches - create new project
onCreateProject(projectName.trim());
}
// Note: Enter does nothing if no exact match and user hasn't navigated with arrows
} else if (event.key === 'Escape') {
event.preventDefault();
setHighlightedIndex(-1);
}
};
return (
<div className="relative">
<div className="flex flex-wrap items-center border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 rounded-md p-2 min-h-[40px]">
{selectedProject ? (
// Only show badge when project is selected - no input allowed
<span className="flex items-center bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 text-sm font-medium px-2.5 py-1 rounded">
{selectedProject.name}
{onClearProject && !disabled && (
<button
type="button"
onClick={onClearProject}
className="ml-1.5 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100 focus:outline-none"
aria-label={`Remove project ${selectedProject.name}`}
>
&times;
</button>
)}
</span>
) : (
// Only show input when no project is selected
<div className="flex-grow relative min-w-[150px]">
<input
type="text"
placeholder={
placeholder ||
t(
'forms.task.projectSearchPlaceholder',
'Search or create a project...'
)
}
value={projectName}
onChange={(e) => onProjectSearch(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
className="w-full bg-transparent border-none outline-none text-sm text-gray-900 dark:text-gray-100 disabled:cursor-not-allowed pr-8"
autoComplete="off"
autoFocus={dropdownOpen}
/>
<button
type="button"
onClick={onShowAllProjects}
disabled={disabled}
className="absolute inset-y-0 right-0 flex items-center pr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
aria-label={
dropdownOpen
? 'Hide projects'
: 'Show all projects'
}
>
{dropdownOpen ? (
<ChevronUpIcon className="h-5 w-5" />
) : (
<ChevronDownIcon className="h-5 w-5" />
)}
</button>
</div>
)}
</div>
{dropdownOpen && !selectedProject && (
<div
ref={dropdownRef}
className="absolute mt-1 bg-white dark:bg-gray-800 shadow-lg rounded-md w-full z-50 border border-gray-200 dark:border-gray-700 max-h-80 overflow-y-auto"
>
{(() => {
// Show filtered projects if user is typing, otherwise show all projects
const projectsToShow = projectName
? filteredProjects
: allProjects;
return projectsToShow.length > 0 ? (
projectsToShow.map((project, index) => (
<button
key={project.id}
type="button"
onClick={() => onProjectSelection(project)}
className={`flex items-center gap-3 w-full text-gray-700 dark:text-gray-300 text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
index === highlightedIndex
? 'bg-blue-50 dark:bg-blue-900/30'
: ''
}`}
>
<div className="w-10 h-10 rounded overflow-hidden flex-shrink-0">
{project.image_url ? (
<img
src={project.image_url}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
)}
</div>
<span className="flex-1 truncate">
{project.name}
</span>
</button>
))
) : (
<div className="px-4 py-2 text-gray-500 dark:text-gray-400">
{projectName
? t(
'forms.task.noMatchingProjects',
'No matching projects'
)
: 'No projects available'}
</div>
);
})()}
{projectName && (
<button
type="button"
onClick={() => onCreateProject(projectName.trim())}
disabled={isCreatingProject}
className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600 transition-colors disabled:bg-blue-400 disabled:cursor-not-allowed"
>
{isCreatingProject
? t('forms.task.creatingProject', 'Creating...')
: t('forms.task.createProject', '+ Create') +
` "${projectName}"`}
</button>
)}
</div>
)}
</div>
);
};
export default ProjectDropdown;