Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3319b85
add stack view button and fix modules header
nazarli-shabnam Mar 16, 2026
4f1e4f1
fix header: add sort icon before 'Name' text
nazarli-shabnam Mar 16, 2026
628f344
replace IconUser with save/bookmark icon in module row
nazarli-shabnam Mar 16, 2026
b22c245
fix: project buttons hover fixtures
nazarli-shabnam Mar 16, 2026
10aa950
add moduleService.create in frontend, design improvements
nazarli-shabnam Mar 16, 2026
50cfafb
feat: implement search, add module button
nazarli-shabnam Mar 17, 2026
2707550
feat: filter and in progress buttons
nazarli-shabnam Mar 17, 2026
a468d46
fix: linting and few errors i found in modules page
nazarli-shabnam Mar 17, 2026
4cf137b
fix: formatting
nazarli-shabnam Mar 17, 2026
974ce88
add moduleService.get and listIssueIds; add route modules/:moduleId
nazarli-shabnam Mar 17, 2026
f2e130e
Create ModuleDetailPage with empty state, work items, actions
nazarli-shabnam Mar 17, 2026
09f7cac
add ModuleDetailHeader in PageHeader (breadcrumbs, view switcher, Fil…
nazarli-shabnam Mar 17, 2026
73b07aa
Redesign ModulesPage timeline to Gantt (left table, right grid, Week/…
nazarli-shabnam Mar 17, 2026
9e149b2
favourites implemented
nazarli-shabnam Mar 17, 2026
23da413
fix: lints, formats
nazarli-shabnam Mar 17, 2026
0da2044
add existing work item modal
nazarli-shabnam Mar 17, 2026
767434d
store filters in state; use name-only module routes
nazarli-shabnam Mar 17, 2026
c975384
feat: 3 dots menu implemented, new component- prefills from the selec…
nazarli-shabnam Mar 18, 2026
d5be1cf
fix: added helpers, updated DateRangeModal to use local date-only par…
nazarli-shabnam Mar 18, 2026
f913298
allow updating module lead_id from Update modal
nazarli-shabnam Mar 18, 2026
3d0fec7
Support updating a module lead through the update flow
nazarli-shabnam Mar 18, 2026
4df111d
fix: duplicate date pop-ups by separating date picker from edit modal…
nazarli-shabnam Mar 18, 2026
62cf69d
instantly sync module favorites sidebar and improve favorites row layout
nazarli-shabnam Mar 18, 2026
3620ff4
fix: address Copilot review - normalize null dates, batch API calls, …
nazarli-shabnam Mar 18, 2026
6713d6c
fix: address Copilot review vol2 - view all buttons, race condition g…
nazarli-shabnam Mar 18, 2026
4536394
fix: linting
nazarli-shabnam Mar 18, 2026
19759cd
fix: local timeline dates and update favorites sidebar display
nazarli-shabnam Mar 18, 2026
fe6d431
fix: remove favorites toast; add timeline date quick-edit; improve pr…
nazarli-shabnam Mar 18, 2026
9b22577
werks?
nazarli-shabnam Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions api/internal/handler/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,26 @@ func (h *ModuleHandler) Update(c *gin.Context) {
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
StartDate string `json:"start_date"`
TargetDate string `json:"target_date"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
StartDate string `json:"start_date"`
TargetDate string `json:"target_date"`
LeadID *string `json:"lead_id"`
}
_ = c.ShouldBindJSON(&body)
Comment thread
martian56 marked this conversation as resolved.
Comment thread
martian56 marked this conversation as resolved.
mod, err := h.Module.Update(c.Request.Context(), slug, projectID, moduleID, user.ID, body.Name, body.Description, body.Status, parseOptionalDate(body.StartDate), parseOptionalDate(body.TargetDate))
var leadIDPtr *uuid.UUID
if body.LeadID != nil {
if *body.LeadID != "" {
id, parseErr := uuid.Parse(*body.LeadID)
if parseErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid lead_id", "detail": "must be a valid UUID"})
return
}
leadIDPtr = &id
}
}
mod, err := h.Module.Update(c.Request.Context(), slug, projectID, moduleID, user.ID, body.Name, body.Description, body.Status, parseOptionalDate(body.StartDate), parseOptionalDate(body.TargetDate), body.LeadID != nil, leadIDPtr)
Comment thread
martian56 marked this conversation as resolved.
if err != nil {
if err == service.ErrModuleNotFound || err == service.ErrProjectForbidden || err == service.ErrProjectNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
Expand Down
5 changes: 4 additions & 1 deletion api/internal/service/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func (s *ModuleService) Get(ctx context.Context, workspaceSlug string, projectID
return mod, nil
}

func (s *ModuleService) Update(ctx context.Context, workspaceSlug string, projectID, moduleID uuid.UUID, userID uuid.UUID, name, description, status string, startDate, targetDate *time.Time) (*model.Module, error) {
func (s *ModuleService) Update(ctx context.Context, workspaceSlug string, projectID, moduleID uuid.UUID, userID uuid.UUID, name, description, status string, startDate, targetDate *time.Time, leadIDSet bool, leadID *uuid.UUID) (*model.Module, error) {
mod, err := s.Get(ctx, workspaceSlug, projectID, moduleID, userID)
if err != nil {
return nil, err
Expand All @@ -120,6 +120,9 @@ func (s *ModuleService) Update(ctx context.Context, workspaceSlug string, projec
if targetDate != nil {
mod.TargetDate = targetDate
}
if leadIDSet {
mod.LeadID = leadID
}
if err := s.ms.Update(ctx, mod); err != nil {
return nil, err
}
Expand Down
1 change: 1 addition & 0 deletions ui/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ export interface ModuleApiResponse {
status: string;
project_id: string;
workspace_id: string;
lead_id?: string | null;
sort_order?: number;
issue_count?: number;
created_at: string;
Expand Down
274 changes: 274 additions & 0 deletions ui/src/components/AddExistingWorkItemModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import { useEffect, useState, useMemo } from "react";
import { createPortal } from "react-dom";
import { Button } from "./ui";
import { issueService } from "../services/issueService";
import { moduleService } from "../services/moduleService";
import type { IssueApiResponse } from "../api/types";

const IconSearch = () => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
);

function displayId(issue: IssueApiResponse, projectIdentifier: string): string {
return `${projectIdentifier}-${issue.sequence_id ?? issue.id.slice(-4)}`;
}

export interface AddExistingWorkItemModalProps {
open: boolean;
onClose: () => void;
workspaceSlug: string;
projectId: string;
moduleId: string;
projectIdentifier: string;
onAdded?: () => void;
}

export function AddExistingWorkItemModal({
open,
onClose,
workspaceSlug,
projectId,
moduleId,
projectIdentifier,
onAdded,
}: AddExistingWorkItemModalProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [projectIssues, setProjectIssues] = useState<IssueApiResponse[]>([]);
const [moduleIssueIds, setModuleIssueIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!open || !workspaceSlug || !projectId || !moduleId) return;
let cancelled = false;
setLoading(true);
setError(null);
Promise.all([
issueService.list(workspaceSlug, projectId, { limit: 2000 }),
moduleService.listIssueIds(workspaceSlug, projectId, moduleId),
])
.then(([issues, ids]) => {
if (cancelled) return;
setProjectIssues(issues ?? []);
setModuleIssueIds(new Set(ids ?? []));
setSelectedIds(new Set());
})
.catch(() => {
if (!cancelled) setError("Failed to load work items");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, workspaceSlug, projectId, moduleId]);
Comment thread
martian56 marked this conversation as resolved.

useEffect(() => {
if (!open) {
setSearchQuery("");
setSelectedIds(new Set());
}
}, [open]);

const availableIssues = useMemo(() => {
return projectIssues.filter((i) => !moduleIssueIds.has(i.id));
}, [projectIssues, moduleIssueIds]);

const filteredIssues = useMemo(() => {
if (!searchQuery.trim()) return availableIssues;
const q = searchQuery.trim().toLowerCase();
return availableIssues.filter((issue) => {
const id = displayId(issue, projectIdentifier);
const name = (issue.name ?? "").toLowerCase();
return id.toLowerCase().includes(q) || name.includes(q);
});
}, [availableIssues, searchQuery, projectIdentifier]);

const selectAll = () => {
if (filteredIssues.length === 0) return;
setSelectedIds((prev) => {
const next = new Set(prev);
filteredIssues.forEach((i) => next.add(i.id));
return next;
});
};

const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};

const handleAdd = async () => {
if (selectedIds.size === 0) return;
setSubmitting(true);
setError(null);
try {
const ids = Array.from(selectedIds);
const BATCH = 5;
for (let i = 0; i < ids.length; i += BATCH) {
await Promise.all(
ids
.slice(i, i + BATCH)
.map((issueId) =>
moduleService.addIssue(
workspaceSlug,
projectId,
moduleId,
issueId,
),
),
);
}
onAdded?.();
onClose();
} catch {
setError("Failed to add work items");
Comment thread
martian56 marked this conversation as resolved.
} finally {
setSubmitting(false);
}
};

const selectedCount = selectedIds.size;
const selectionLabel =
selectedCount === 0
? "No work items selected"
: `${selectedCount} work item${selectedCount === 1 ? "" : "s"} selected`;

const footer = (
<div className="flex w-full items-center justify-between">
<button
type="button"
onClick={selectAll}
className="text-sm font-medium text-(--brand-default) hover:underline"
>
Select all
</button>
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
onClick={handleAdd}
disabled={selectedCount === 0 || submitting}
className="gap-1.5"
>
Add selected work items
</Button>
</div>
</div>
);

if (!open) return null;

return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="add-existing-work-item-title"
>
<div
className="absolute inset-0 bg-(--bg-backdrop)"
onClick={onClose}
aria-hidden
/>
<div
className="relative z-10 flex w-full max-w-md flex-col rounded-lg border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-overlay)"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-3 border-b border-(--border-subtle) px-5 py-4">
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-(--txt-icon-tertiary)">
<IconSearch />
</span>
<input
type="text"
placeholder="Type to search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-9 w-full rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-2 pl-9 pr-3 text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none"
aria-label="Search work items"
/>
</div>
<span
id="add-existing-work-item-title"
className="inline-flex w-fit rounded-full bg-(--bg-layer-2) px-3 py-1 text-sm text-(--txt-secondary)"
>
{selectionLabel}
</span>
</div>

<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-5 py-4">
{error && (
<p className="mb-2 text-sm text-(--txt-danger-primary)">{error}</p>
)}
{loading ? (
<p className="py-8 text-center text-sm text-(--txt-tertiary)">
Loading work items…
</p>
) : filteredIssues.length === 0 ? (
<p className="py-8 text-center text-sm text-(--txt-tertiary)">
{availableIssues.length === 0
? "No other work items in this project."
: "No matching work items."}
</p>
) : (
<ul className="max-h-64 overflow-y-auto divide-y divide-(--border-subtle)">
{filteredIssues.map((issue) => {
const id = displayId(issue, projectIdentifier);
const checked = selectedIds.has(issue.id);
return (
<li key={issue.id}>
<label className="flex cursor-pointer items-center gap-3 py-2.5 hover:bg-(--bg-layer-1-hover)">
<input
type="checkbox"
checked={checked}
onChange={() => toggleOne(issue.id)}
className="size-4 shrink-0 rounded border-(--border-subtle)"
/>
<span
className="size-2 shrink-0 rounded-full bg-(--txt-primary)"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate text-sm">
<span className="font-medium text-(--txt-primary)">
{id}
</span>
<span className="ml-2 text-(--txt-secondary)">
{issue.name || "—"}
</span>
</span>
</label>
</li>
);
})}
</ul>
)}
</div>

<div className="flex w-full border-t border-(--border-subtle) px-5 py-4">
{footer}
</div>
</div>
</div>,
document.body,
);
}
Loading
Loading