Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 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
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
254 changes: 254 additions & 0 deletions ui/src/components/AddExistingWorkItemModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
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;
setLoading(true);
setError(null);
Promise.all([
issueService.list(workspaceSlug, projectId, { limit: 2000 }),
moduleService.listIssueIds(workspaceSlug, projectId, moduleId),
])
.then(([issues, ids]) => {
setProjectIssues(issues ?? []);
setModuleIssueIds(new Set(ids ?? []));
setSelectedIds(new Set());
})
.catch(() => setError("Failed to load work items"))
.finally(() => setLoading(false));
Comment thread
martian56 marked this conversation as resolved.
Outdated
}, [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 {
await Promise.all(
Array.from(selectedIds).map((issueId) =>
moduleService.addIssue(workspaceSlug, projectId, moduleId, issueId),
),
);
Comment thread
martian56 marked this conversation as resolved.
Outdated
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,
);
}
42 changes: 20 additions & 22 deletions ui/src/components/CoverImageModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Button, Modal } from "./ui";
import {
instanceSettingsService,
Expand Down Expand Up @@ -164,25 +164,25 @@ export function CoverImageModal({
footer={footer}
className="max-w-2xl"
>
<div className="flex gap-2 border-b border-[var(--border-subtle)] pb-3 mb-3">
<div className="flex gap-2 border-b border-(--border-subtle) pb-3 mb-3">
<button
type="button"
onClick={() => setTab(TAB_UNSPLASH)}
className={`rounded-[var(--radius-md)] px-3 py-1.5 text-sm font-medium transition-colors ${
className={`rounded-(--radius-md) px-3 py-1.5 text-sm font-medium transition-colors ${
tab === TAB_UNSPLASH
? "bg-[var(--brand-default)] text-white"
: "text-[var(--txt-secondary)] hover:bg-[var(--bg-layer-transparent-hover)]"
? "bg-(--brand-default) text-white"
: "text-(--txt-secondary) hover:bg-(--bg-layer-transparent-hover)"
}`}
>
Unsplash
</button>
<button
type="button"
onClick={() => setTab(TAB_UPLOAD)}
className={`rounded-[var(--radius-md)] px-3 py-1.5 text-sm font-medium transition-colors ${
className={`rounded-(--radius-md) px-3 py-1.5 text-sm font-medium transition-colors ${
tab === TAB_UPLOAD
? "bg-[var(--brand-default)] text-white"
: "text-[var(--txt-secondary)] hover:bg-[var(--bg-layer-transparent-hover)]"
? "bg-(--brand-default) text-white"
: "text-(--txt-secondary) hover:bg-(--bg-layer-transparent-hover)"
}`}
>
Upload
Expand All @@ -198,7 +198,7 @@ export function CoverImageModal({
onChange={(e) => setUnsplashQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search for images"
className="min-w-0 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] px-3 py-2 text-sm text-[var(--txt-primary)] placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]"
className="min-w-0 flex-1 rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) px-3 py-2 text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none focus:border-(--border-strong)"
/>
<Button
variant="secondary"
Expand All @@ -209,7 +209,7 @@ export function CoverImageModal({
</Button>
</div>
{unsplashError && (
<p className="text-sm text-[var(--txt-danger-primary)]">
<p className="text-sm text-(--txt-danger-primary)">
{unsplashError}
</p>
)}
Expand All @@ -219,10 +219,10 @@ export function CoverImageModal({
type="button"
key={r.id}
onClick={() => setSelectedUrl(r.url)}
className={`relative aspect-video rounded-[var(--radius-md)] overflow-hidden border-2 transition-colors ${
className={`relative aspect-video rounded-(--radius-md) overflow-hidden border-2 transition-colors ${
selectedUrl === r.url
? "border-[var(--brand-default)] ring-2 ring-[var(--brand-200)]"
: "border-transparent hover:border-[var(--border-strong)]"
? "border-(--brand-default) ring-2 ring-(--brand-200)"
: "border-transparent hover:border-(--border-strong)"
}`}
>
<img
Expand All @@ -242,13 +242,13 @@ export function CoverImageModal({
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
className="flex flex-col items-center justify-center rounded-[var(--radius-md)] border-2 border-dashed border-[var(--border-subtle)] bg-[var(--bg-layer-2)] py-12 px-4"
className="flex flex-col items-center justify-center rounded-(--radius-md) border-2 border-dashed border-(--border-subtle) bg-(--bg-layer-2) py-12 px-4"
>
<p className="text-sm text-[var(--txt-secondary)] mb-2">
<p className="text-sm text-(--txt-secondary) mb-2">
Drag & drop image here
</p>
<label className="cursor-pointer">
<span className="text-sm font-medium text-[var(--txt-accent-primary)] hover:underline">
<span className="text-sm font-medium text-(--txt-accent-primary) hover:underline">
Browse
</span>
<input
Expand All @@ -260,7 +260,7 @@ export function CoverImageModal({
</label>
</div>
) : (
<div className="relative rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden bg-[var(--bg-layer-2)]">
<div className="relative rounded-(--radius-md) border border-(--border-subtle) overflow-hidden bg-(--bg-layer-2)">
<img
src={uploadPreview}
alt="Preview"
Expand All @@ -270,20 +270,18 @@ export function CoverImageModal({
<button
type="button"
onClick={handleRemoveUpload}
className="text-xs font-medium text-[var(--txt-accent-primary)] hover:underline"
className="text-xs font-medium text-(--txt-accent-primary) hover:underline"
>
Edit
</button>
</div>
</div>
)}
<p className="text-xs text-[var(--txt-tertiary)]">
<p className="text-xs text-(--txt-tertiary)">
File formats supported: .jpeg, .jpg, .png, .webp
</p>
{uploadError && (
<p className="text-sm text-[var(--txt-danger-primary)]">
{uploadError}
</p>
<p className="text-sm text-(--txt-danger-primary)">{uploadError}</p>
)}
</div>
)}
Expand Down
Loading
Loading