-
Notifications
You must be signed in to change notification settings - Fork 61
[FEAT] Implement modules page projects modules #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
nazarli-shabnam
merged 29 commits into
main
from
34-implement-modules-page-projects-modules
Mar 18, 2026
Merged
Changes from 20 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 4f1e4f1
fix header: add sort icon before 'Name' text
nazarli-shabnam 628f344
replace IconUser with save/bookmark icon in module row
nazarli-shabnam b22c245
fix: project buttons hover fixtures
nazarli-shabnam 10aa950
add moduleService.create in frontend, design improvements
nazarli-shabnam 50cfafb
feat: implement search, add module button
nazarli-shabnam 2707550
feat: filter and in progress buttons
nazarli-shabnam a468d46
fix: linting and few errors i found in modules page
nazarli-shabnam 4cf137b
fix: formatting
nazarli-shabnam 974ce88
add moduleService.get and listIssueIds; add route modules/:moduleId
nazarli-shabnam f2e130e
Create ModuleDetailPage with empty state, work items, actions
nazarli-shabnam 09f7cac
add ModuleDetailHeader in PageHeader (breadcrumbs, view switcher, Fil…
nazarli-shabnam 73b07aa
Redesign ModulesPage timeline to Gantt (left table, right grid, Week/…
nazarli-shabnam 9e149b2
favourites implemented
nazarli-shabnam 23da413
fix: lints, formats
nazarli-shabnam 0da2044
add existing work item modal
nazarli-shabnam 767434d
store filters in state; use name-only module routes
nazarli-shabnam c975384
feat: 3 dots menu implemented, new component- prefills from the selec…
nazarli-shabnam d5be1cf
fix: added helpers, updated DateRangeModal to use local date-only par…
nazarli-shabnam f913298
allow updating module lead_id from Update modal
nazarli-shabnam 3d0fec7
Support updating a module lead through the update flow
nazarli-shabnam 4df111d
fix: duplicate date pop-ups by separating date picker from edit modal…
nazarli-shabnam 62cf69d
instantly sync module favorites sidebar and improve favorites row layout
nazarli-shabnam 3620ff4
fix: address Copilot review - normalize null dates, batch API calls, …
nazarli-shabnam 6713d6c
fix: address Copilot review vol2 - view all buttons, race condition g…
nazarli-shabnam 4536394
fix: linting
nazarli-shabnam 19759cd
fix: local timeline dates and update favorites sidebar display
nazarli-shabnam fe6d431
fix: remove favorites toast; add timeline date quick-edit; improve pr…
nazarli-shabnam 9b22577
werks?
nazarli-shabnam File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)); | ||
| }, [open, workspaceSlug, projectId, moduleId]); | ||
|
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), | ||
| ), | ||
| ); | ||
|
martian56 marked this conversation as resolved.
Outdated
|
||
| onAdded?.(); | ||
| onClose(); | ||
| } catch { | ||
| setError("Failed to add work items"); | ||
|
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, | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.