* 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
306 lines
14 KiB
TypeScript
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}`}
|
|
>
|
|
×
|
|
</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;
|