Files
TimeTracker/app/templates/tasks/create.html
T
Dries Peeters b4b8bafb9a feat: Add comprehensive form validation system with real-time feedback
Implement a reusable form validation system that provides immediate,
contextual feedback to users with inline error messages and visual indicators.

Features:
- Real-time validation on input, blur, and submit events
- Inline error and success messages displayed near form fields
- Visual indicators for required vs optional fields (asterisks)
- Subtle validation styling with softer colors and smaller icons
- Phone number validation for tel/phone fields (7-15 digits, optional country code)
- Email, URL, number, date, and pattern validation support
- Debounced validation to reduce performance impact
- Form-level error messages on submit
- Automatic focus management for invalid fields

Technical improvements:
- Prevent duplicate initialization with form and field flags
- Smart message container insertion that respects existing form structure
- Better detection of existing required indicators to prevent duplicates
- Hidden messages take zero space (height: 0) to prevent layout shifts
- Graceful error handling with try-catch blocks

Styling:
- Subtle visual feedback with green-300/red-300 borders (softer than before)
- Smaller validation icons (0.875rem) and reduced padding (2rem)
- Reduced opacity for messages (0.75-0.85) for less intrusive appearance
- Lighter focus shadows (0.08 opacity) for subtle feedback
- Dark mode support with appropriate color adjustments

Applied to all forms:
- Projects (create/edit)
- Clients (create/edit)
- Tasks (create/edit)
- Invoices (create/edit)
- Payments (create/edit)
- Expenses, Mileage, Per Diem forms
- Time Entry (manual entry)
- Weekly Goals

Fixes:
- Prevent duplicate message containers and layout breaks
- Better insertion logic that respects existing help text
- Improved container detection to avoid duplicates
- Fixed required indicator duplication issues
- Enhanced form submission handler management

The validation system automatically initializes on forms with
data-validate-form attribute or novalidate attribute, providing
consistent validation UX across the application.
2025-11-05 08:02:30 +01:00

469 lines
22 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('Create Task') }} - Time Tracker{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css">
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.css">
{% endblock %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ _('Create Task') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Add a new task to your project to break down work into manageable components') }}</p>
</div>
<a href="{{ url_for('tasks.list_tasks') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Back to Tasks') }}</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST" id="createTaskForm" novalidate data-validate-form>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Task Name -->
<div class="mb-4">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Task Name') }} *</label>
<input type="text" id="name" name="name" value="{{ request.form.get('name', '') }}" placeholder="{{ _('Enter a descriptive task name') }}" required class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Choose a clear, descriptive name that explains what needs to be done') }}</p>
</div>
<!-- Description -->
<div class="mb-4">
<div class="flex items-center justify-between">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Description') }}</label>
<small class="text-text-muted-light dark:text-text-muted-dark">{{ _('Supports Markdown') }}</small>
</div>
<div class="markdown-editor-wrapper">
<textarea class="form-input hidden" id="description" name="description" rows="12" placeholder="{{ _('Provide detailed information about the task, requirements, and any specific instructions...') }}">{{ request.form.get('description', '') }}</textarea>
<div id="description_editor"></div>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Add context, requirements, or specific instructions for the task') }}</p>
</div>
<!-- Project Selection -->
<div class="mb-4">
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Project') }} *</label>
<select id="project_id" name="project_id" required class="form-input">
<option value="">{{ _('Select a project') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if request.form.get('project_id')|int == project.id or request.args.get('project_id')|int == project.id %}selected{% endif %}>{{ project.name }} ({{ project.client }})</option>
{% endfor %}
</select>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Select the project this task belongs to') }}</p>
</div>
<!-- Priority and Status -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div>
<label for="priority" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Priority') }}</label>
<select id="priority" name="priority" class="form-input">
<option value="low" {% if request.form.get('priority') == 'low' %}selected{% endif %}>{{ _('Low') }}</option>
<option value="medium" {% if request.form.get('priority') == 'medium' or not request.form.get('priority') %}selected{% endif %}>{{ _('Medium') }}</option>
<option value="high" {% if request.form.get('priority') == 'high' %}selected{% endif %}>{{ _('High') }}</option>
<option value="urgent" {% if request.form.get('priority') == 'urgent' %}selected{% endif %}>{{ _('Urgent') }}</option>
</select>
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Initial Status') }}</label>
<select id="status" name="status" class="form-input">
<option value="todo" {% if request.form.get('status') == 'todo' or not request.form.get('status') %}selected{% endif %}>{{ _('To Do') }}</option>
<option value="in_progress" {% if request.form.get('status') == 'in_progress' %}selected{% endif %}>{{ _('In Progress') }}</option>
<option value="review" {% if request.form.get('status') == 'review' %}selected{% endif %}>{{ _('Review') }}</option>
<option value="done" {% if request.form.get('status') == 'done' %}selected{% endif %}>{{ _('Done') }}</option>
<option value="cancelled" {% if request.form.get('status') == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 mb-4 text-xs">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Preview') }}:</span>
<span id="priorityPreview" class="priority-badge priority-{{ request.form.get('priority', 'medium') }}">
{% if request.form.get('priority') == 'low' %}{{ _('Low') }}{% elif request.form.get('priority') == 'high' %}{{ _('High') }}{% elif request.form.get('priority') == 'urgent' %}{{ _('Urgent') }}{% else %}{{ _('Medium') }}{% endif %}
</span>
<span id="statusPreview" class="status-badge status-{{ request.form.get('status', 'todo') }}">
{% if request.form.get('status') == 'in_progress' %}{{ _('In Progress') }}{% elif request.form.get('status') == 'review' %}{{ _('Review') }}{% elif request.form.get('status') == 'done' %}{{ _('Done') }}{% elif request.form.get('status') == 'cancelled' %}{{ _('Cancelled') }}{% else %}{{ _('To Do') }}{% endif %}
</span>
</div>
<!-- Due Date and Estimated Hours -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="due_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Due Date') }}</label>
<input type="date" id="due_date" name="due_date" value="{{ request.form.get('due_date', '') }}" class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Set a deadline for this task') }}</p>
</div>
<div>
<label for="estimated_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Estimated Hours') }}</label>
<input type="number" id="estimated_hours" name="estimated_hours" value="{{ request.form.get('estimated_hours', '') }}" step="0.5" min="0" placeholder="0.0" class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Estimate how long this task will take') }}</p>
</div>
</div>
<!-- Assignment -->
<div class="mb-4">
<label for="assigned_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Assign To') }}</label>
<select id="assigned_to" name="assigned_to" class="form-input">
<option value="">{{ _('Unassigned') }}</option>
{% for user in users %}
<option value="{{ user.id }}" {% if request.form.get('assigned_to')|int == user.id %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %}
</select>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional: Assign this task to a team member') }}</p>
</div>
<!-- Form Actions -->
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
<a href="{{ url_for('tasks.list_tasks') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Create Task') }}</button>
</div>
</form>
</div>
</div>
<!-- Sidebar -->
<div class="lg:col-span-1">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow space-y-4 text-sm" data-testid="task-create-tips">
<h3 class="text-lg font-semibold">{{ _('Task Creation Tips') }}</h3>
<ul class="space-y-2" role="list">
<li class="tip-item flex items-start gap-3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-sky-500/10 text-sky-600 dark:text-sky-400" aria-hidden="true">
<i class="fas fa-bullseye text-[13px]"></i>
</span>
<div>
<strong class="block">{{ _('Clear Naming') }}</strong>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Use action verbs and be specific about what needs to be done') }}</span>
</div>
</li>
<li class="tip-item flex items-start gap-3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400" aria-hidden="true">
<i class="fas fa-hourglass-half text-[13px]"></i>
</span>
<div>
<strong class="block">{{ _('Realistic Estimates') }}</strong>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Consider complexity and dependencies when estimating time') }}</span>
</div>
</li>
<li class="tip-item flex items-start gap-3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-indigo-500/10 text-indigo-600 dark:text-indigo-400" aria-hidden="true">
<i class="fas fa-calendar-alt text-[13px]"></i>
</span>
<div>
<strong class="block">{{ _('Set Deadlines') }}</strong>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Due dates help prioritize work and track progress') }}</span>
</div>
</li>
<li class="tip-item flex items-start gap-3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-rose-500/10 text-rose-600 dark:text-rose-400" aria-hidden="true">
<i class="fas fa-flag text-[13px]"></i>
</span>
<div>
<strong class="block">{{ _('Priority Matters') }}</strong>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _("Use priority levels to help team members focus on what's most important") }}</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<style>
/* Priority Badges */
.priority-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.priority-low {
background-color: #dcfce7;
color: #166534;
}
.priority-medium {
background-color: #fef3c7;
color: #92400e;
}
.priority-high {
background-color: #fed7aa;
color: #c2410c;
}
.priority-urgent {
background-color: #fee2e2;
color: #991b1b;
}
/* Status Badges */
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-todo {
background-color: #e2e8f0;
color: #475569;
}
.status-in_progress {
background-color: #fef3c7;
color: #92400e;
}
.status-review {
background-color: #dbeafe;
color: #1e40af;
}
.status-done {
background-color: #dcfce7;
color: #166534;
}
.status-cancelled {
background-color: #fee2e2;
color: #991b1b;
}
/* Tip Items */
.tip-item {
padding: 0.75rem;
border-radius: 8px;
background-color: #f8fafc;
transition: all 0.2s ease;
}
.tip-item:hover {
background-color: #f1f5f9;
transform: translateX(4px);
}
/* Dark mode for tip items */
.dark .tip-item {
background-color: #0f172a; /* slate-900 */
}
.dark .tip-item:hover {
background-color: #0b1220; /* slightly lighter hover */
}
/* Priority and Status Guide Items */
.priority-guide-item,
.status-guide-item {
padding: 0.5rem;
border-radius: 6px;
transition: all 0.2s ease;
}
.priority-guide-item:hover,
.status-guide-item:hover {
background-color: #f8fafc;
}
/* Form Styling */
.form-control:focus,
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
}
.form-control-lg {
min-height: 56px;
}
.form-select-lg {
min-height: 56px;
}
/* Mobile Optimizations */
@media (max-width: 768px) {
.card-header {
padding: 1rem 1rem 0.75rem 1rem;
}
.card-body {
padding: 0.75rem 1rem;
}
.tip-item:hover {
transform: none;
}
.priority-guide-item:hover,
.status-guide-item:hover {
background-color: transparent;
}
}
/* Hover Effects */
.btn:hover {
transform: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
}
.tip-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* Enhanced markdown editor styles are now centralized in base.css */
</style>
<!-- Toast UI Editor -->
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<script>
// Form validation and enhancement
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('createTaskForm');
const nameInput = document.getElementById('name');
const descriptionInput = document.getElementById('description');
const prioritySelect = document.getElementById('priority');
const statusSelect = document.getElementById('status');
const priorityPreview = document.getElementById('priorityPreview');
const statusPreview = document.getElementById('statusPreview');
let mdEditor = null;
// Initialize Toast UI Editor
if (descriptionInput && window.toastui && window.toastui.Editor) {
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
mdEditor = new toastui.Editor({
el: document.getElementById('description_editor'),
height: '380px',
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
usageStatistics: false,
hideModeSwitch: false,
placeholder: 'Provide detailed information about the task, requirements, and any specific instructions...',
theme: theme,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task'],
['link', 'code', 'codeblock', 'table'],
['image'],
['scrollSync']
],
initialValue: descriptionInput.value || ''
});
// Apply theme changes dynamically if supported
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && mdEditor) {
const nextTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
try {
if (typeof mdEditor.setTheme === 'function') {
mdEditor.setTheme(nextTheme);
}
} catch (e) {}
}
});
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// Image upload hook
mdEditor.removeHook && mdEditor.removeHook('addImageBlobHook');
mdEditor.addHook && mdEditor.addHook('addImageBlobHook', async (blob, callback) => {
try {
const formData = new FormData();
formData.append('image', blob, blob.name || 'upload.png');
const res = await fetch('{{ url_for('api.upload_editor_image') }}', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data && data.url) {
callback(data.url, blob.name || 'image');
} else {
console.warn('Upload failed', data);
}
} catch (e) { console.error('Image upload error', e); }
});
// Autosave to localStorage
const autosaveKey = 'tt-task-create-description';
if (!descriptionInput.value) {
const cached = localStorage.getItem(autosaveKey);
if (cached) { try { mdEditor.setMarkdown(cached); } catch(e) {} }
}
mdEditor.on && mdEditor.on('change', () => {
try { localStorage.setItem(autosaveKey, mdEditor.getMarkdown()); } catch (e) {}
});
}
// Form submission enhancement
form.addEventListener('submit', function(e) {
const name = nameInput.value.trim();
if (!name) {
e.preventDefault();
nameInput.focus();
nameInput.classList.add('is-invalid');
return false;
}
// Sync markdown content back to hidden textarea
if (mdEditor && descriptionInput) {
try { descriptionInput.value = mdEditor.getMarkdown(); } catch (err) {}
}
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Creating...';
submitBtn.disabled = true;
// Re-enable after a delay (in case of validation errors)
setTimeout(() => {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}, 5000);
});
// Real-time validation feedback
nameInput.addEventListener('input', function() {
if (this.value.trim()) {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
}
});
// Live badge previews
function updateBadge(element, value, type) {
if (!element) return;
const map = type === 'priority' ? {
low: { text: '{{ _('Low') }}', class: 'priority-low' },
medium: { text: '{{ _('Medium') }}', class: 'priority-medium' },
high: { text: '{{ _('High') }}', class: 'priority-high' },
urgent: { text: '{{ _('Urgent') }}', class: 'priority-urgent' },
} : {
todo: { text: '{{ _('To Do') }}', class: 'status-todo' },
in_progress: { text: '{{ _('In Progress') }}', class: 'status-in_progress' },
review: { text: '{{ _('Review') }}', class: 'status-review' },
done: { text: '{{ _('Done') }}', class: 'status-done' },
cancelled: { text: '{{ _('Cancelled') }}', class: 'status-cancelled' },
};
const def = map[value] || Object.values(map)[0];
// Reset classes preserving base class
element.className = element.className.split(' ').filter(c => !c.startsWith(type === 'priority' ? 'priority-' : 'status-')).join(' ').trim();
element.classList.add(def.class);
element.textContent = def.text;
}
function handlePreviewChange(){
updateBadge(priorityPreview, prioritySelect?.value || 'medium', 'priority');
updateBadge(statusPreview, statusSelect?.value || 'todo', 'status');
}
prioritySelect && prioritySelect.addEventListener('change', handlePreviewChange);
statusSelect && statusSelect.addEventListener('change', handlePreviewChange);
handlePreviewChange();
});
</script>
{% endblock %}