- Updated the status component template to include a new trigger for task status checks. - Modified the status component's Go implementation to reflect the new trigger in the HTML output. - Improved the UpdateIssue handler to conditionally set the assignee based on form input. - Enhanced the dashboard template to include a new dependencies section for issues, allowing users to add dependencies dynamically. - Refactored the dashboard's issue rows to utilize JavaScript functions for rendering status, type, and priority badges, improving maintainability and readability.
438 lines
18 KiB
Plaintext
438 lines
18 KiB
Plaintext
package routes
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/LazyBachelor/LazyPM/internal/models"
|
|
"github.com/LazyBachelor/LazyPM/pkg/web/components/base"
|
|
)
|
|
|
|
type DashboardProps struct {
|
|
Issues []*models.Issue
|
|
SelectedIssue *models.Issue
|
|
BaseURL string
|
|
QueryParam string
|
|
EmptyText string
|
|
}
|
|
|
|
templ Dashboard(props DashboardProps) {
|
|
@BaseLayout() {
|
|
@DashboardContent(props)
|
|
}
|
|
}
|
|
|
|
templ DashboardContent(props DashboardProps) {
|
|
{{
|
|
selectedID := ""
|
|
if props.SelectedIssue != nil {
|
|
selectedID = props.SelectedIssue.ID
|
|
}
|
|
issuesJSON, _ := templ.JSONString(props.Issues)
|
|
xData := fmt.Sprintf(`{ selectedId: %q, issues: %s, getStatusClass(s) { switch(s) { case 'open': return 'badge-info'; case 'in_progress': return 'badge-warning'; case 'closed': return 'badge-success'; case 'blocked': return 'badge-error'; default: return 'badge-ghost'; } }, getTypeClass(t) { switch(t) { case 'bug': return 'badge-outline badge-error'; case 'feature': return 'badge-outline badge-success'; case 'task': return 'badge-outline badge-info'; case 'chore': return 'badge-outline badge-warning'; case 'epic': return 'badge-outline badge-secondary'; default: return 'badge-outline'; } }, getPriorityClass(p) { p = parseInt(p); switch(p) { case 0: case 1: return 'badge-ghost'; case 2: return 'badge-info'; case 3: return 'badge-warning'; case 4: return 'badge-error'; default: return 'badge-ghost'; } }, formatStatus(s) { return (s || '').replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase()); }, formatType(t) { return (t || '').charAt(0).toUpperCase() + (t || '').slice(1); }, formatPriority(p) { p = parseInt(p); switch(p) { case 0: return 'Irrelevant'; case 1: return 'Low'; case 2: return 'Normal'; case 3: return 'High'; case 4: return 'Critical'; default: return 'P'+p; } } }`, selectedID, issuesJSON)
|
|
}}
|
|
<div class="flex flex-col h-full" x-data={ xData }>
|
|
<div class="flex flex-col border-b border-base-200">
|
|
<div class="flex p-2 justify-between items-center gap-2">
|
|
<div class="flex gap-2 items-center">
|
|
<div class="join">
|
|
<button class="btn btn-sm join-item btn-active btn-primary">List</button>
|
|
<button
|
|
class="btn btn-sm join-item"
|
|
hx-get="/?board=true"
|
|
hx-target="main"
|
|
hx-swap="innerHTML"
|
|
hx-push-url="true"
|
|
>Board</button>
|
|
</div>
|
|
<button
|
|
class="btn btn-primary btn-sm"
|
|
hx-get="/issues/create"
|
|
hx-target="#modal-container"
|
|
hx-swap="innerHTML"
|
|
>
|
|
New Issue
|
|
</button>
|
|
</div>
|
|
<div class="flex gap-2" x-show="selectedId">
|
|
<button
|
|
class="btn btn-sm hidden lg:inline-flex"
|
|
@click="window.location.href = '/issues/' + selectedId"
|
|
>
|
|
Full Details
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="flex flex-1 overflow-hidden relative flex-row"
|
|
x-data="{
|
|
listWidth: localStorage.getItem('dashboardListWidth') || 33.33,
|
|
isResizing: false,
|
|
startX: 0,
|
|
startWidth: 0,
|
|
init() {
|
|
this.listWidth = parseFloat(this.listWidth);
|
|
},
|
|
startResize(e) {
|
|
this.isResizing = true;
|
|
this.startX = e.clientX || e.touches[0].clientX;
|
|
this.startWidth = this.listWidth;
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
},
|
|
stopResize() {
|
|
if (this.isResizing) {
|
|
this.isResizing = false;
|
|
localStorage.setItem('dashboardListWidth', this.listWidth);
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
}
|
|
},
|
|
resize(e) {
|
|
if (!this.isResizing) return;
|
|
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
|
const delta = ((clientX - this.startX) / window.innerWidth) * 100;
|
|
this.listWidth = Math.max(20, Math.min(60, this.startWidth + delta));
|
|
}
|
|
}"
|
|
@mousemove.window="resize($event)"
|
|
@mouseup.window="stopResize()"
|
|
@touchmove.window="resize($event)"
|
|
@touchend.window="stopResize()"
|
|
>
|
|
<div
|
|
class="flex flex-col border-r border-base-200 overflow-auto bg-base-100 lg:flex lg:w-auto"
|
|
:style="`width: ${listWidth}%`"
|
|
>
|
|
<div id="issue-list-container" class="p-2">
|
|
@DashboardIssueList(props.Issues, selectedID)
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="w-1 bg-base-300 hover:bg-primary cursor-col-resize transition-colors relative z-10"
|
|
:class="{ 'bg-primary': isResizing }"
|
|
@mousedown="startResize($event)"
|
|
@touchstart="startResize($event)"
|
|
title="Drag to resize"
|
|
>
|
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-8 bg-base-300 rounded-full flex items-center justify-center">
|
|
<div class="w-0.5 h-4 bg-base-content/30 rounded-full"></div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="flex-1 p-4 overflow-auto bg-base-50"
|
|
>
|
|
<template x-if="issues.length === 0">
|
|
<div class="max-w-2xl mx-auto mt-12">
|
|
<div class="card bg-base-100 shadow-sm border border-base-200">
|
|
<div class="card-body items-center text-center">
|
|
<h3 class="text-xl font-semibold">No issues yet</h3>
|
|
<p class="text-base-content/70">Create your first issue to get started.</p>
|
|
<button class="btn btn-primary btn-sm mt-2" hx-get="/issues/create" hx-target="#modal-container" hx-swap="innerHTML">New Issue</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="issues.length > 0 && !selectedId">
|
|
<div class="max-w-2xl mx-auto mt-12">
|
|
<div class="alert alert-info">Select an issue from the list to view details.</div>
|
|
</div>
|
|
</template>
|
|
<template x-for="issue in issues" :key="issue.id">
|
|
<div x-show="issue.id === selectedId" class="issue-detail max-w-3xl mx-auto" x-data="{ editing: null, saving: false, newComment: '', async saveField(field, value) { const formData = new URLSearchParams(); formData.append(field, value || ''); await fetch('/issues/' + issue.id, { method: 'PATCH', body: formData, headers: { 'HX-Request': 'true' } }); this.editing = null; }, async addComment() { if (!this.newComment.trim()) return; const author = issue.created_by || 'Anonymous'; const response = await fetch('/issues/' + issue.id + '/comments', { method: 'POST', body: new URLSearchParams({text: this.newComment, author: author}) }); const newComment = await response.json(); if (!issue.comments) issue.comments = []; issue.comments.push(newComment); this.newComment = ''; } }">
|
|
<div class="card bg-base-100 shadow-md">
|
|
<div class="card-body p-2">
|
|
<div class="m-2">
|
|
<template x-if="editing !== 'title'">
|
|
<h2
|
|
class="text-2xl font-bold cursor-pointer hover:text-primary"
|
|
x-text="issue.title"
|
|
@click="editing = 'title'"
|
|
></h2>
|
|
</template>
|
|
<template x-if="editing === 'title'">
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="text"
|
|
name="title"
|
|
x-model="issue.title"
|
|
class="input input-bordered flex-1"
|
|
@keydown.enter="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({title: issue.title}) }).then(() => editing = null)"
|
|
@keydown.escape="editing = null"
|
|
x-init="$el.focus()"
|
|
/>
|
|
<button class="btn btn-sm btn-primary" @click="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({title: issue.title}) }).then(() => editing = null)">Save</button>
|
|
<button class="btn btn-sm" @click.stop="editing = null">Cancel</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="flex gap-2 flex-wrap items-center">
|
|
<template x-if="editing !== 'status'">
|
|
<span
|
|
class="badge badge-info badge-sm cursor-pointer hover:badge-primary whitespace-nowrap shrink-0"
|
|
x-text="issue.status.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())"
|
|
@click="editing = 'status'"
|
|
></span>
|
|
</template>
|
|
<template x-if="editing === 'status'">
|
|
<div class="flex items-center gap-2">
|
|
<select
|
|
name="status"
|
|
x-model="issue.status"
|
|
class="select select-sm select-bordered"
|
|
@change="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({status: issue.status}) }).then(() => editing = null)"
|
|
x-init="$nextTick(() => $el.focus())"
|
|
>
|
|
<option value="open">Open</option>
|
|
<option value="in_progress">In Progress</option>
|
|
<option value="blocked">Blocked</option>
|
|
<option value="closed">Closed</option>
|
|
</select>
|
|
<button class="btn btn-xs" @click.stop="editing = null">Cancel</button>
|
|
</div>
|
|
</template>
|
|
<template x-if="editing !== 'type'">
|
|
<span
|
|
class="badge badge-outline badge-sm cursor-pointer hover:badge-primary whitespace-nowrap"
|
|
x-text="issue.issue_type"
|
|
@click="editing = 'type'"
|
|
></span>
|
|
</template>
|
|
<template x-if="editing === 'type'">
|
|
<div class="flex items-center gap-2">
|
|
<select
|
|
name="issue_type"
|
|
x-model="issue.issue_type"
|
|
class="select select-sm select-bordered"
|
|
@change="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({issue_type: issue.issue_type}) }).then(() => editing = null)"
|
|
x-init="$nextTick(() => $el.focus())"
|
|
>
|
|
<option value="task">Task</option>
|
|
<option value="bug">Bug</option>
|
|
<option value="feature">Feature</option>
|
|
<option value="chore">Chore</option>
|
|
</select>
|
|
<button class="btn btn-xs" @click="editing = null">Cancel</button>
|
|
</div>
|
|
</template>
|
|
<template x-if="editing !== 'priority'">
|
|
<span
|
|
class="badge badge-ghost badge-sm cursor-pointer hover:badge-primary whitespace-nowrap"
|
|
x-text="'P' + issue.priority"
|
|
@click="editing = 'priority'"
|
|
></span>
|
|
</template>
|
|
<template x-if="editing === 'priority'">
|
|
<div class="flex items-center gap-2">
|
|
<select
|
|
name="priority"
|
|
x-model="issue.priority"
|
|
class="select select-sm select-bordered"
|
|
@change="fetch('/issues/' + issue.id, { method: 'PATCH', body: new URLSearchParams({priority: issue.priority}) }).then(() => editing = null)"
|
|
x-init="$nextTick(() => $el.focus())"
|
|
>
|
|
<option value="0">Irrelevant (P0)</option>
|
|
<option value="1">Low (P1)</option>
|
|
<option value="2">Normal (P2)</option>
|
|
<option value="3">High (P3)</option>
|
|
<option value="4">Critical (P4)</option>
|
|
</select>
|
|
<button class="btn btn-xs" @click.stop="editing = null">Cancel</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="mt-4">
|
|
<h3 class="text-sm font-semibold opacity-70 mb-1">Description</h3>
|
|
<template x-if="editing !== 'description'">
|
|
<p
|
|
class="whitespace-pre-wrap text-sm cursor-pointer hover:text-primary min-h-[20px]"
|
|
x-text="issue.description || 'Click to add description...'"
|
|
@click="editing = 'description'"
|
|
></p>
|
|
</template>
|
|
<template x-if="editing === 'description'">
|
|
<div class="flex flex-col gap-2">
|
|
<textarea
|
|
name="description"
|
|
x-model="issue.description"
|
|
class="textarea textarea-bordered"
|
|
rows="4"
|
|
@keydown.escape="editing = null"
|
|
x-init="$el.focus()"
|
|
></textarea>
|
|
<div class="flex gap-2">
|
|
<button
|
|
class="btn btn-sm btn-primary"
|
|
@click="saveField('description', issue.description)"
|
|
>Save</button>
|
|
<button class="btn btn-sm" @click.stop="editing = null">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="divider my-2"></div>
|
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
|
<div class="bg-base-200 rounded-lg p-2">
|
|
<span class="text-xs opacity-70 block">Created</span>
|
|
<p x-text="new Date(issue.created_at).toLocaleDateString()"></p>
|
|
</div>
|
|
<div class="bg-base-200 rounded-lg p-2">
|
|
<span class="text-xs opacity-70 block">Updated</span>
|
|
<p x-text="new Date(issue.updated_at).toLocaleDateString()"></p>
|
|
</div>
|
|
<div class="flex flex-row gap-1 bg-base-200 rounded-lg p-2">
|
|
<span class="text-xs opacity-70 block">Created by</span>
|
|
<p class="text-xs opacity-70" x-text="issue.created_by"></p>
|
|
</div>
|
|
<div class="bg-base-200 rounded-lg p-2 cursor-pointer hover:bg-base-300" @click="editing = 'assignee'">
|
|
<template x-if="editing !== 'assignee'">
|
|
<div>
|
|
<span class="text-xs opacity-70 block">Assignee</span>
|
|
<p class="text-sm" x-text="issue.assignee || 'Unassigned (click to assign)'"></p>
|
|
</div>
|
|
</template>
|
|
<template x-if="editing === 'assignee'">
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-xs opacity-70 block">Assignee</span>
|
|
<input
|
|
type="text"
|
|
name="assignee"
|
|
x-model="issue.assignee"
|
|
class="input input-sm input-bordered"
|
|
placeholder="Enter assignee name"
|
|
@keydown.enter="saveField('assignee', issue.assignee)"
|
|
@keydown.escape="editing = null"
|
|
x-init="$el.focus()"
|
|
/>
|
|
<div class="flex gap-1 mt-1">
|
|
<button
|
|
class="btn btn-xs btn-primary"
|
|
@click="saveField('assignee', issue.assignee)"
|
|
>Save</button>
|
|
<button class="btn btn-xs" @click.stop="editing = null">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4">
|
|
<h3 class="text-sm font-semibold opacity-70 mb-2">Dependencies</h3>
|
|
<form class="flex gap-2 items-end mb-3">
|
|
<div class="flex-1">
|
|
<select
|
|
class="select select-bordered select-sm w-full"
|
|
name="depends_on_id"
|
|
x-bind:hx-get="'/issues/' + issue.id + '/dependencies/options'"
|
|
hx-trigger="load, dependencies-updated from:body"
|
|
hx-target="this"
|
|
hx-swap="innerHTML"
|
|
>
|
|
<option>Loading...</option>
|
|
</select>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm btn-secondary"
|
|
x-bind:hx-post="'/issues/' + issue.id + '/dependencies?container_id=deps-' + issue.id"
|
|
x-bind:hx-target="'#deps-' + issue.id"
|
|
hx-swap="innerHTML"
|
|
hx-include="closest form"
|
|
>
|
|
Add
|
|
</button>
|
|
</form>
|
|
<div
|
|
x-bind:id="'deps-' + issue.id"
|
|
x-bind:hx-get="'/issues/' + issue.id + '/dependencies?container_id=deps-' + issue.id"
|
|
hx-trigger="load, dependencies-updated from:body"
|
|
hx-swap="innerHTML"
|
|
>
|
|
<div class="text-sm text-base-content/60">Loading dependencies...</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-6 w-full">
|
|
<h3 class="text-lg font-semibold mb-4">Comments</h3>
|
|
<div class="card bg-base-100 shadow-sm mb-3">
|
|
<div class="card-body p-3">
|
|
<textarea
|
|
x-model="newComment"
|
|
class="textarea textarea-bordered textarea-sm w-full"
|
|
rows="2"
|
|
placeholder="Add a comment..."
|
|
@keydown.enter.prevent="addComment()"
|
|
></textarea>
|
|
<div class="card-actions justify-end mt-2">
|
|
<button
|
|
class="btn btn-primary btn-xs"
|
|
@click="addComment()"
|
|
:disabled="!newComment.trim()"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-4">
|
|
<template x-for="comment in issue.comments" :key="comment.id">
|
|
<div class="card bg-base-200">
|
|
<div class="card-body py-3">
|
|
<div class="flex justify-between items-start mb-2">
|
|
<span class="font-semibold text-sm" x-text="comment.author"></span>
|
|
<span class="text-xs opacity-50" x-text="new Date(comment.created_at).toLocaleString()"></span>
|
|
</div>
|
|
<p class="whitespace-pre-wrap text-sm" x-text="comment.text"></p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ DashboardIssueList(issues []*models.Issue, selectedID string) {
|
|
<section class="flex">
|
|
@base.Table(
|
|
[]templ.Component{
|
|
base.PlainText("ID"),
|
|
base.PlainText("Title"),
|
|
base.PlainText("Status"),
|
|
base.PlainText("Type"),
|
|
base.PlainText("Priority"),
|
|
},
|
|
[]templ.Component{
|
|
DashboardIssueRows(issues, selectedID),
|
|
},
|
|
templ.Attributes{},
|
|
)
|
|
</section>
|
|
}
|
|
|
|
templ DashboardIssueRows(issues []*models.Issue, selectedID string) {
|
|
if len(issues) == 0 {
|
|
<tr><td colspan="5" class="text-center py-4">No issues</td></tr>
|
|
}
|
|
for _, issue := range issues {
|
|
<tr
|
|
@click={ fmt.Sprintf(`selectedId = '%s'`, issue.ID) }
|
|
:class={ fmt.Sprintf(`{ 'bg-primary/10': selectedId === '%s' }`, issue.ID) }
|
|
class="hover cursor-pointer min-h-full w-full select-none"
|
|
data-issue-id={ issue.ID }
|
|
hx-get={ "/issues/" + issue.ID + "/edit" }
|
|
hx-trigger="dblclick"
|
|
hx-target="#modal-container"
|
|
hx-swap="innerHTML"
|
|
>
|
|
<td class="min-w-20 truncate">{ issue.ID }</td>
|
|
<td class="truncate max-w-60" x-text={ fmt.Sprintf(`issues.find(i => i.id === '%s')?.title || %q`, issue.ID, issue.Title) }></td>
|
|
<td x-html={ fmt.Sprintf(`'<span class="badge ' + getStatusClass(issues.find(i => i.id === '%s')?.status) + ' badge-sm whitespace-nowrap">' + formatStatus(issues.find(i => i.id === '%s')?.status || '%s') + '</span>'`, issue.ID, issue.ID, issue.Status) }></td>
|
|
<td x-html={ fmt.Sprintf(`'<span class="badge ' + getTypeClass(issues.find(i => i.id === '%s')?.issue_type) + ' badge-sm whitespace-nowrap">' + formatType(issues.find(i => i.id === '%s')?.issue_type || '%s') + '</span>'`, issue.ID, issue.ID, issue.IssueType) }></td>
|
|
<td x-html={ fmt.Sprintf(`'<span class="badge ' + getPriorityClass(issues.find(i => i.id === '%s')?.priority) + ' badge-sm whitespace-nowrap">' + formatPriority(issues.find(i => i.id === '%s')?.priority || %d) + '</span>'`, issue.ID, issue.ID, issue.Priority) }></td>
|
|
</tr>
|
|
}
|
|
}
|