From 22f7f65d47ce867284e86a4dd743870a63fa393d Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Mon, 23 Mar 2026 22:48:58 +0400 Subject: [PATCH 01/12] page init --- ui/src/components/layout/PageHeader.tsx | 13 ++++- ui/src/pages/ModuleDetailPage.tsx | 74 ++++++++++++++++++------- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index fc2a45a6..c3b3dbde 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -34,6 +34,7 @@ import { PROJECT_CYCLES_REFRESH_EVENT, } from '../../lib/projectCyclesEvents'; import { PROJECT_VIEWS_FILTER_EVENT } from '../../lib/projectViewsEvents'; +import { slugify } from '../../lib/slug'; export type ProjectSection = 'issues' | 'cycles' | 'modules' | 'views' | 'pages'; @@ -2877,10 +2878,16 @@ export function PageHeader() { return; } let cancelled = false; + const key = moduleId.trim().toLowerCase(); moduleService - .get(workspaceSlug, projectId, moduleId) - .then((m) => { - if (!cancelled) setModule(m ?? null); + .list(workspaceSlug, projectId) + .then((mods) => { + if (cancelled) return; + const found = + (mods ?? []).find((x) => x.id === moduleId) ?? + (mods ?? []).find((x) => slugify(x.name) === key) ?? + null; + setModule(found); }) .catch(() => { if (!cancelled) setModule(null); diff --git a/ui/src/pages/ModuleDetailPage.tsx b/ui/src/pages/ModuleDetailPage.tsx index 45b1e451..90d77cd4 100644 --- a/ui/src/pages/ModuleDetailPage.tsx +++ b/ui/src/pages/ModuleDetailPage.tsx @@ -315,31 +315,67 @@ export function ModuleDetailPage() {
{/* Empty state */} {issues.length === 0 && ( -
- -
-

+
+
+

No work items in the module

- Create or add work items which you want to accomplish as part of this module. + Create or add work items which you want to accomplish as part of this module

-
- - + +
+
+
+
+ +
+ +
+
+ Backlog + 7 +
+
+ {[1, 2, 3, 4, 5].map((n) => ( +
+ ))} +
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
-
+
)} {/* Work items by state */} From c0221ea6f77c130e51d5e9d26bef283061f5b315 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Wed, 25 Mar 2026 05:55:47 +0400 Subject: [PATCH 02/12] feat: add moduleWorkItemsPrefs lib and filter/display panels --- .../components/layout/ModuleDetailHeader.tsx | 499 ++++++++++++++++++ .../ModuleWorkItemsToolbarPanels.tsx | 315 +++++++++++ ui/src/lib/moduleWorkItemsApply.ts | 110 ++++ ui/src/lib/moduleWorkItemsPrefs.ts | 98 ++++ 4 files changed, 1022 insertions(+) create mode 100644 ui/src/components/layout/ModuleDetailHeader.tsx create mode 100644 ui/src/components/module-work-items/ModuleWorkItemsToolbarPanels.tsx create mode 100644 ui/src/lib/moduleWorkItemsApply.ts create mode 100644 ui/src/lib/moduleWorkItemsPrefs.ts diff --git a/ui/src/components/layout/ModuleDetailHeader.tsx b/ui/src/components/layout/ModuleDetailHeader.tsx new file mode 100644 index 00000000..147ce5ec --- /dev/null +++ b/ui/src/components/layout/ModuleDetailHeader.tsx @@ -0,0 +1,499 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Button } from '../ui'; +import { Dropdown } from '../work-item'; +import { DateRangeModal } from '../workspace-views/DateRangeModal'; +import { ProjectIconDisplay } from '../ProjectIconModal'; +import { + ModuleWorkItemsDisplayPanel, + ModuleWorkItemsFiltersPanel, +} from '../module-work-items/ModuleWorkItemsToolbarPanels'; +import { workspaceService } from '../../services/workspaceService'; +import { stateService } from '../../services/stateService'; +import type { ProjectApiResponse, StateApiResponse, WorkspaceMemberApiResponse } from '../../api/types'; +import { + DEFAULT_MODULE_WORK_ITEMS_DISPLAY, + DEFAULT_MODULE_WORK_ITEMS_FILTERS, + MODULE_WORK_ITEMS_DISPLAY_EVENT, + MODULE_WORK_ITEMS_FILTER_EVENT, + MODULE_WORK_ITEMS_OPEN_ADD_EXISTING_EVENT, + isModuleFiltersActive, + moduleWorkItemsPrefsKey, + parseModuleWorkItemsPrefs, + serializeModuleWorkItemsPrefs, + type ModuleWorkItemsDisplayState, + type ModuleWorkItemsFiltersState, +} from '../../lib/moduleWorkItemsPrefs'; + +const IconChevronDown = () => ( + + + +); + +const IconList = () => ( + + + + + + + + +); + +const IconKanban = () => ( + + + + + + + +); + +const IconCalendar = () => ( + + + + + + +); + +const IconSpreadsheet = () => ( + + + + + + +); + +const IconGantt = () => ( + + + + + + + + +); + +const IconFilter = () => ( + + + +); + +const IconSliders = () => ( + + + + + + + + + + + +); + +const IconPlus = () => ( + + + + +); + +const IconMoreVertical = () => ( + + + + + +); + +export interface ModuleDetailHeaderProps { + workspaceSlug: string; + projectId: string; + project: ProjectApiResponse; + projectName: string; + moduleId: string; + moduleName: string; + moduleRouteParam: string; + issueCountBadge: number; +} + +export function ModuleDetailHeader({ + workspaceSlug, + projectId, + project, + projectName, + moduleId, + moduleName, + moduleRouteParam, + issueCountBadge, +}: ModuleDetailHeaderProps) { + const navigate = useNavigate(); + const baseUrl = `/${workspaceSlug}/projects/${projectId}`; + const modulePath = `${baseUrl}/modules/${encodeURIComponent(moduleRouteParam)}`; + + const [moduleDropdownOpen, setModuleDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + const moreRef = useRef(null); + const [moreOpen, setMoreOpen] = useState(false); + const [toolbarOpen, setToolbarOpen] = useState(null); + + const [states, setStates] = useState([]); + const [members, setMembers] = useState([]); + const [filters, setFilters] = useState(DEFAULT_MODULE_WORK_ITEMS_FILTERS); + const [display, setDisplay] = useState(DEFAULT_MODULE_WORK_ITEMS_DISPLAY); + const [dateModal, setDateModal] = useState<'due' | 'start' | null>(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setModuleDropdownOpen(false); + } + if (moreRef.current && !moreRef.current.contains(e.target as Node)) setMoreOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + useEffect(() => { + let cancelled = false; + Promise.all([ + stateService.list(workspaceSlug, projectId), + workspaceService.listMembers(workspaceSlug), + ]) + .then(([st, mem]) => { + if (!cancelled) { + setStates(st ?? []); + setMembers(mem ?? []); + } + }) + .catch(() => { + if (!cancelled) { + setStates([]); + setMembers([]); + } + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug, projectId]); + + useEffect(() => { + const key = moduleWorkItemsPrefsKey(workspaceSlug, projectId, moduleId); + const raw = localStorage.getItem(key); + const parsed = parseModuleWorkItemsPrefs(raw); + if (parsed) { + setFilters({ ...DEFAULT_MODULE_WORK_ITEMS_FILTERS, ...parsed.filters }); + setDisplay({ ...DEFAULT_MODULE_WORK_ITEMS_DISPLAY, ...parsed.display }); + } else { + setFilters(DEFAULT_MODULE_WORK_ITEMS_FILTERS); + setDisplay(DEFAULT_MODULE_WORK_ITEMS_DISPLAY); + } + }, [workspaceSlug, projectId, moduleId]); + + useEffect(() => { + const key = moduleWorkItemsPrefsKey(workspaceSlug, projectId, moduleId); + localStorage.setItem( + key, + serializeModuleWorkItemsPrefs({ + filters, + display, + }), + ); + }, [workspaceSlug, projectId, moduleId, filters, display]); + + useEffect(() => { + window.dispatchEvent(new CustomEvent(MODULE_WORK_ITEMS_FILTER_EVENT, { detail: filters })); + }, [filters]); + + useEffect(() => { + window.dispatchEvent(new CustomEvent(MODULE_WORK_ITEMS_DISPLAY_EVENT, { detail: display })); + }, [display]); + + const filtersActive = isModuleFiltersActive(filters); + + const openAddExisting = () => { + setMoreOpen(false); + window.dispatchEvent(new Event(MODULE_WORK_ITEMS_OPEN_ADD_EXISTING_EVENT)); + }; + + const copyLink = async () => { + setMoreOpen(false); + try { + await navigator.clipboard.writeText(window.location.href); + } catch { + /* ignore */ + } + }; + + const openNewTab = () => { + setMoreOpen(false); + window.open(window.location.href, '_blank', 'noopener,noreferrer'); + }; + + return ( + <> +
+ + + + + {projectName} + + / + + Modules + + / +
+ + + {issueCountBadge} + + {moduleDropdownOpen && ( +
+ setModuleDropdownOpen(false)} + > + All modules + +
+ )} +
+
+
+
+ + + + + +
+ +
+ } + displayValue="" + align="right" + compact + triggerContent={ + + + + Filters + + + {filtersActive ? ( + + ) : null} + + } + > + setDateModal('due')} + onRequestStartCustom={() => setDateModal('start')} + /> + +
+ + } + displayValue="" + align="right" + compact + triggerContent={ + + + Display + + + } + > + + + + + Analytics + + + + + + +
+ + {moreOpen && ( +
+ + + +
+ )} +
+
+ + setDateModal(null)} + title="Due date range" + after={filters.dueAfter} + before={filters.dueBefore} + onApply={(after, before) => { + setFilters((p) => ({ + ...p, + duePreset: 'custom', + dueAfter: after, + dueBefore: before, + })); + setDateModal(null); + }} + /> + setDateModal(null)} + title="Start date range" + after={filters.startAfter} + before={filters.startBefore} + onApply={(after, before) => { + setFilters((p) => ({ + ...p, + startAfter: after, + startBefore: before, + })); + setDateModal(null); + }} + /> + + ); +} diff --git a/ui/src/components/module-work-items/ModuleWorkItemsToolbarPanels.tsx b/ui/src/components/module-work-items/ModuleWorkItemsToolbarPanels.tsx new file mode 100644 index 00000000..baa901a9 --- /dev/null +++ b/ui/src/components/module-work-items/ModuleWorkItemsToolbarPanels.tsx @@ -0,0 +1,315 @@ +import { type Dispatch, type SetStateAction, useState } from 'react'; +import { CollapsibleSection } from '../workspace-views/WorkspaceViewsFiltersShared'; +import { Avatar } from '../ui'; +import { getImageUrl } from '../../lib/utils'; +import type { StateApiResponse, WorkspaceMemberApiResponse } from '../../api/types'; +import { + type ModuleDueDatePreset, + type ModuleWorkItemsDisplayState, + type ModuleWorkItemsFiltersState, +} from '../../lib/moduleWorkItemsPrefs'; + +const PRIORITIES = ['urgent', 'high', 'medium', 'low', 'none'] as const; + +const DUE_PRESETS: { id: ModuleDueDatePreset; label: string }[] = [ + { id: 'none', label: 'Any due date' }, + { id: 'overdue', label: 'Overdue' }, + { id: 'this_week', label: 'Due this week' }, + { id: 'no_due', label: 'No due date' }, + { id: 'custom', label: 'Custom range…' }, +]; + +export interface ModuleWorkItemsFiltersPanelProps { + filters: ModuleWorkItemsFiltersState; + setFilters: Dispatch>; + states: StateApiResponse[]; + members: WorkspaceMemberApiResponse[]; + onRequestDueCustom: () => void; + onRequestStartCustom: () => void; +} + +export function ModuleWorkItemsFiltersPanel({ + filters, + setFilters, + states, + members, + onRequestDueCustom, + onRequestStartCustom, +}: ModuleWorkItemsFiltersPanelProps) { + const [open, setOpen] = useState({ + priority: true, + state: true, + assignee: true, + due: true, + start: false, + }); + + const toggle = (key: keyof typeof open) => setOpen((o) => ({ ...o, [key]: !o[key] })); + + const togglePriority = (p: string) => { + setFilters((prev) => { + const next = new Set(prev.priorityKeys); + if (next.has(p)) next.delete(p); + else next.add(p); + return { ...prev, priorityKeys: [...next] }; + }); + }; + + const toggleState = (stateId: string) => { + setFilters((prev) => { + const next = new Set(prev.stateIds); + if (next.has(stateId)) next.delete(stateId); + else next.add(stateId); + return { ...prev, stateIds: [...next] }; + }); + }; + + const toggleAssignee = (memberId: string) => { + setFilters((prev) => { + const next = new Set(prev.assigneeMemberIds); + if (next.has(memberId)) next.delete(memberId); + else next.add(memberId); + return { ...prev, assigneeMemberIds: [...next] }; + }); + }; + + return ( +
+ toggle('priority')} + > +
+ {PRIORITIES.map((p) => { + const on = filters.priorityKeys.includes(p); + return ( + + ); + })} +
+
+ toggle('state')}> +
+ + {states.map((s) => ( + + ))} +
+
+ toggle('assignee')}> +
+ {members.map((m) => { + const id = m.member_id; + const label = + m.member_display_name?.trim() || + m.member_email?.split('@')[0]?.trim() || + 'Member'; + const on = filters.assigneeMemberIds.includes(id); + return ( + + ); + })} +
+
+ toggle('due')}> +
+ {DUE_PRESETS.map((pr) => ( + + ))} +
+
+ toggle('start')}> +
+ + {(filters.startAfter || filters.startBefore) && ( +

+ {[filters.startAfter, filters.startBefore].filter(Boolean).join(' → ')} +

+ )} +
+
+
+ ); +} + +export interface ModuleWorkItemsDisplayPanelProps { + display: ModuleWorkItemsDisplayState; + setDisplay: Dispatch>; +} + +export function ModuleWorkItemsDisplayPanel({ display, setDisplay }: ModuleWorkItemsDisplayPanelProps) { + const [open, setOpen] = useState({ layout: true, props: true }); + const toggle = (key: keyof typeof open) => setOpen((o) => ({ ...o, [key]: !o[key] })); + + const rowToggle = (key: keyof ModuleWorkItemsDisplayState) => { + setDisplay((p) => ({ ...p, [key]: !p[key] })); + }; + + return ( +
+ toggle('layout')}> +
+ {( + [ + [false, 'All work items'], + [true, 'Group by state'], + ] as const + ).map(([val, label]) => ( + + ))} + +
+
+ toggle('props')}> +
+ {( + [ + ['showState', 'State'], + ['showPriority', 'Priority'], + ['showStartDate', 'Start date'], + ['showDueDate', 'Due date'], + ['showAssignee', 'Assignees'], + ['showModule', 'Module'], + ['showLabels', 'Labels'], + ['showVisibility', 'Visibility'], + ] as const + ).map(([k, label]) => ( + + ))} +
+
+
+ ); +} diff --git a/ui/src/lib/moduleWorkItemsApply.ts b/ui/src/lib/moduleWorkItemsApply.ts new file mode 100644 index 00000000..897ed65b --- /dev/null +++ b/ui/src/lib/moduleWorkItemsApply.ts @@ -0,0 +1,110 @@ +import type { IssueApiResponse } from '../api/types'; +import type { ModuleWorkItemsDisplayState, ModuleWorkItemsFiltersState } from './moduleWorkItemsPrefs'; + +function startOfWeek(d: Date): Date { + const x = new Date(d); + const day = x.getDay(); + const diff = (day + 6) % 7; + x.setDate(x.getDate() - diff); + x.setHours(0, 0, 0, 0); + return x; +} + +function endOfWeek(d: Date): Date { + const s = startOfWeek(d); + const e = new Date(s); + e.setDate(e.getDate() + 6); + e.setHours(23, 59, 59, 999); + return e; +} + +export function filterModuleIssues( + issues: IssueApiResponse[], + f: ModuleWorkItemsFiltersState, +): IssueApiResponse[] { + const now = new Date(); + const sod = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + + return issues.filter((issue) => { + if (f.priorityKeys.length > 0) { + const p = issue.priority ?? 'none'; + if (!f.priorityKeys.includes(p)) return false; + } + if (f.stateIds.length > 0) { + const sid = issue.state_id ?? ''; + const wantNone = f.stateIds.includes('__none__'); + const inList = Boolean(sid) && f.stateIds.includes(sid); + const isNone = !sid; + if (!inList && !(isNone && wantNone)) return false; + } + if (f.assigneeMemberIds.length > 0) { + const aids = issue.assignee_ids ?? []; + if (!aids.some((id) => f.assigneeMemberIds.includes(id))) return false; + } + + if (f.duePreset !== 'none') { + const td = + issue.target_date != null && issue.target_date !== '' + ? new Date(issue.target_date).getTime() + : null; + if (f.duePreset === 'no_due') { + if (issue.target_date) return false; + } else if (f.duePreset === 'overdue') { + if (td == null || td >= sod) return false; + } else if (f.duePreset === 'this_week') { + if (issue.target_date == null || issue.target_date === '') return false; + const t = new Date(issue.target_date); + if (t < startOfWeek(now) || t > endOfWeek(now)) return false; + } else if (f.duePreset === 'custom') { + if (f.dueAfter) { + const a = new Date(f.dueAfter).getTime(); + if (td == null || td < a) return false; + } + if (f.dueBefore) { + const b = new Date(f.dueBefore).getTime(); + if (td == null || td > b) return false; + } + } + } + + if (f.startAfter || f.startBefore) { + const sd = + issue.start_date != null && issue.start_date !== '' + ? new Date(issue.start_date).getTime() + : null; + if (f.startAfter) { + const a = new Date(f.startAfter).getTime(); + if (sd == null || sd < a) return false; + } + if (f.startBefore) { + const b = new Date(f.startBefore).getTime(); + if (sd == null || sd > b) return false; + } + } + + return true; + }); +} + +export function applyModuleSubWorkFilter( + issues: IssueApiResponse[], + display: ModuleWorkItemsDisplayState, +): IssueApiResponse[] { + if (display.showSubWorkItems) return issues; + return issues.filter((i) => !i.parent_id); +} + +export function sortModuleIssuesDefault( + issues: IssueApiResponse[], + stateOrder: Map, +): IssueApiResponse[] { + return [...issues].sort((a, b) => { + const sa = a.state_id ? (stateOrder.get(a.state_id) ?? 999) : 999; + const sb = b.state_id ? (stateOrder.get(b.state_id) ?? 999) : 999; + if (sa !== sb) return sa - sb; + const na = a.sequence_id ?? 0; + const nb = b.sequence_id ?? 0; + if (na !== nb) return na - nb; + return (a.name || '').localeCompare(b.name || ''); + }); +} diff --git a/ui/src/lib/moduleWorkItemsPrefs.ts b/ui/src/lib/moduleWorkItemsPrefs.ts new file mode 100644 index 00000000..812ec196 --- /dev/null +++ b/ui/src/lib/moduleWorkItemsPrefs.ts @@ -0,0 +1,98 @@ +export const MODULE_WORK_ITEMS_FILTER_EVENT = 'devlane:module-work-items-filter'; +export const MODULE_WORK_ITEMS_DISPLAY_EVENT = 'devlane:module-work-items-display'; +export const MODULE_WORK_ITEMS_COUNT_EVENT = 'devlane:module-work-items-count'; +export const MODULE_WORK_ITEMS_OPEN_ADD_EXISTING_EVENT = + 'devlane:module-work-items-open-add-existing'; + +export type ModuleDueDatePreset = 'none' | 'overdue' | 'this_week' | 'no_due' | 'custom'; + +export interface ModuleWorkItemsFiltersState { + priorityKeys: string[]; + stateIds: string[]; + assigneeMemberIds: string[]; + duePreset: ModuleDueDatePreset; + dueAfter: string | null; + dueBefore: string | null; + startAfter: string | null; + startBefore: string | null; +} + +export const DEFAULT_MODULE_WORK_ITEMS_FILTERS: ModuleWorkItemsFiltersState = { + priorityKeys: [], + stateIds: [], + assigneeMemberIds: [], + duePreset: 'none', + dueAfter: null, + dueBefore: null, + startAfter: null, + startBefore: null, +}; + +export interface ModuleWorkItemsDisplayState { + groupByState: boolean; + showSubWorkItems: boolean; + showState: boolean; + showPriority: boolean; + showStartDate: boolean; + showDueDate: boolean; + showAssignee: boolean; + showModule: boolean; + showLabels: boolean; + showVisibility: boolean; +} + +export const DEFAULT_MODULE_WORK_ITEMS_DISPLAY: ModuleWorkItemsDisplayState = { + groupByState: false, + showSubWorkItems: true, + showState: true, + showPriority: true, + showStartDate: true, + showDueDate: true, + showAssignee: true, + showModule: true, + showLabels: true, + showVisibility: true, +}; + +export interface PersistedModuleWorkItemsPrefs { + filters: ModuleWorkItemsFiltersState; + display: ModuleWorkItemsDisplayState; +} + +export function moduleWorkItemsPrefsKey( + workspaceSlug: string, + projectId: string, + moduleId: string, +): string { + return `devlane:module-work-items:${workspaceSlug}:${projectId}:${moduleId}`; +} + +export function parseModuleWorkItemsPrefs(raw: string | null): PersistedModuleWorkItemsPrefs | null { + if (!raw) return null; + try { + const p = JSON.parse(raw) as PersistedModuleWorkItemsPrefs; + if (!p || typeof p !== 'object') return null; + const filters = { ...DEFAULT_MODULE_WORK_ITEMS_FILTERS, ...p.filters }; + const display = { ...DEFAULT_MODULE_WORK_ITEMS_DISPLAY, ...p.display }; + return { filters, display }; + } catch { + return null; + } +} + +export function serializeModuleWorkItemsPrefs(p: PersistedModuleWorkItemsPrefs): string { + return JSON.stringify(p); +} + +export function isModuleFiltersActive(f: ModuleWorkItemsFiltersState): boolean { + return ( + f.priorityKeys.length > 0 || + f.stateIds.length > 0 || + f.assigneeMemberIds.length > 0 || + f.duePreset !== 'none' || + Boolean(f.dueAfter) || + Boolean(f.dueBefore) || + Boolean(f.startAfter) || + Boolean(f.startBefore) + ); +} From 8fc266c26f7d36e0472912e2389eb97307d43558 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Wed, 25 Mar 2026 05:59:39 +0400 Subject: [PATCH 03/12] refactor: ModuleDetailPage: flat list, rich rows, events --- .../components/layout/ModuleDetailHeader.tsx | 4 + ui/src/components/layout/PageHeader.tsx | 238 +------ ui/src/pages/ModuleDetailPage.tsx | 628 +++++++++++++----- 3 files changed, 473 insertions(+), 397 deletions(-) diff --git a/ui/src/components/layout/ModuleDetailHeader.tsx b/ui/src/components/layout/ModuleDetailHeader.tsx index 147ce5ec..9c95db57 100644 --- a/ui/src/components/layout/ModuleDetailHeader.tsx +++ b/ui/src/components/layout/ModuleDetailHeader.tsx @@ -367,6 +367,8 @@ export function ModuleDetailHeader({ displayValue="" align="right" compact + panelClassName="max-h-[min(85vh,32rem)] overflow-hidden rounded-md border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-raised)" + triggerClassName="inline-flex border-0 bg-transparent p-0 shadow-none hover:bg-transparent" triggerContent={ @@ -400,6 +402,8 @@ export function ModuleDetailHeader({ displayValue="" align="right" compact + panelClassName="max-h-[min(85vh,32rem)] overflow-hidden rounded-md border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-raised)" + triggerClassName="inline-flex border-0 bg-transparent p-0 shadow-none hover:bg-transparent" triggerContent={ diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 152f196e..f60f2ade 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -52,6 +52,8 @@ import { } from '../../lib/projectIssuesDisplay'; import { PROJECT_VIEWS_FILTER_EVENT } from '../../lib/projectViewsEvents'; import { slugify } from '../../lib/slug'; +import { MODULE_WORK_ITEMS_COUNT_EVENT } from '../../lib/moduleWorkItemsPrefs'; +import { ModuleDetailHeader } from './ModuleDetailHeader'; export type ProjectSection = 'issues' | 'cycles' | 'modules' | 'views' | 'pages'; @@ -802,217 +804,6 @@ function ProjectDetailHeader({ ); } -const IconListAlt = () => ( - - - -); -const IconBarChartModule = () => ( - - - -); -const IconLayoutGridModule = () => ( - - - - - - -); -const IconSliders = () => ( - - - - - - - - - - - -); - -function ModuleDetailHeader({ - workspaceSlug, - projectId, - project, - projectName, - moduleName, -}: { - workspaceSlug: string; - projectId: string; - project: ProjectApiResponse; - projectName: string; - moduleId: string; - moduleName: string; -}) { - const baseUrl = `/${workspaceSlug}/projects/${projectId}`; - const [moduleDropdownOpen, setModuleDropdownOpen] = useState(false); - const [viewLayout, setViewLayout] = useState< - 'list' | 'board' | 'calendar' | 'gallery' | 'timeline' - >('list'); - const ref = useRef(null); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setModuleDropdownOpen(false); - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const viewButtons: { - id: typeof viewLayout; - icon: React.ReactNode; - label: string; - }[] = [ - { id: 'list', icon: , label: 'List' }, - { id: 'board', icon: , label: 'Board' }, - { id: 'calendar', icon: , label: 'Calendar' }, - { id: 'gallery', icon: , label: 'Gallery' }, - { id: 'timeline', icon: , label: 'Timeline' }, - ]; - - return ( - <> -
- - - - - {projectName} - - / - - Modules - - / -
- - {moduleDropdownOpen && ( -
- setModuleDropdownOpen(false)} - > - All modules - -
- )} -
-
-
-
- {viewButtons.map((b, i) => ( - - ))} -
- - - - - - - -
- - ); -} - function ProjectSectionHeader({ workspaceSlug, projectId, @@ -3006,6 +2797,16 @@ export function PageHeader() { const [project, setProject] = useState(null); const [projectIssueCount, setProjectIssueCount] = useState(0); const [module, setModule] = useState(null); + const [moduleWorkItemCount, setModuleWorkItemCount] = useState(null); + + useEffect(() => { + const onCount = (e: Event) => { + const d = (e as CustomEvent<{ count: number }>).detail; + if (d && typeof d.count === 'number') setModuleWorkItemCount(d.count); + }; + window.addEventListener(MODULE_WORK_ITEMS_COUNT_EVENT, onCount); + return () => window.removeEventListener(MODULE_WORK_ITEMS_COUNT_EVENT, onCount); + }, []); useEffect(() => { if (!workspaceSlug) { @@ -3090,6 +2891,13 @@ export function PageHeader() { const pathname = location.pathname; + useEffect(() => { + const base = workspaceSlug && projectId ? `/${workspaceSlug}/projects/${projectId}` : ''; + const norm = pathname.replace(/\/+$/, '') || pathname; + const onModuleDetail = Boolean(base && moduleId && norm === `${base}/modules/${moduleId}`); + if (!onModuleDetail) setModuleWorkItemCount(null); + }, [pathname, workspaceSlug, projectId, moduleId]); + // Match route patterns to pick header const isWorkspaceHome = workspaceSlug && pathname === `/${workspaceSlug}`; const isSettings = @@ -3098,12 +2906,12 @@ export function PageHeader() { pathname.startsWith(`/${workspaceSlug}/settings/`)); const isProjectsList = workspaceSlug && pathname === `/${workspaceSlug}/projects`; const projectBase = workspaceSlug && projectId ? `/${workspaceSlug}/projects/${projectId}` : ''; + const pathNoTrailingSlash = pathname.replace(/\/+$/, '') || pathname; const isIssuesPage = projectBase && pathname === `${projectBase}/issues`; const isCyclesPage = projectBase && pathname === `${projectBase}/cycles`; const isModulesPage = projectBase && pathname === `${projectBase}/modules`; const isModuleDetailPage = - projectBase && moduleId && pathname === `${projectBase}/modules/${moduleId}`; - const pathNoTrailingSlash = pathname.replace(/\/+$/, '') || pathname; + projectBase && moduleId && pathNoTrailingSlash === `${projectBase}/modules/${moduleId}`; const isViewsListPage = projectBase && pathNoTrailingSlash === `${projectBase}/views`; const isProjectSavedViewDetailPage = projectBase && !!viewId && pathNoTrailingSlash === `${projectBase}/views/${viewId}`; @@ -3149,7 +2957,7 @@ export function PageHeader() { content = ; } else if (isWorkspaceViewsPage && workspaceSlug) { content = ; - } else if (isModuleDetailPage && workspaceSlug && projectId && project && module) { + } else if (isModuleDetailPage && workspaceSlug && projectId && project && module && moduleId) { content = ( ); } else if (isProjectSavedViewDetailPage && workspaceSlug && projectId && viewId && project) { diff --git a/ui/src/pages/ModuleDetailPage.tsx b/ui/src/pages/ModuleDetailPage.tsx index 90d77cd4..e7c8e953 100644 --- a/ui/src/pages/ModuleDetailPage.tsx +++ b/ui/src/pages/ModuleDetailPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router-dom'; import { Avatar, Badge, Button } from '../components/ui'; import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; @@ -9,6 +9,7 @@ import { moduleService } from '../services/moduleService'; import { issueService } from '../services/issueService'; import { stateService } from '../services/stateService'; import { labelService } from '../services/labelService'; +import { cycleService } from '../services/cycleService'; import type { WorkspaceApiResponse, ProjectApiResponse, @@ -17,10 +18,28 @@ import type { StateApiResponse, LabelApiResponse, WorkspaceMemberApiResponse, + CycleApiResponse, } from '../api/types'; import type { Priority } from '../types'; import { getImageUrl } from '../lib/utils'; import { slugify } from '../lib/slug'; +import { + DEFAULT_MODULE_WORK_ITEMS_DISPLAY, + DEFAULT_MODULE_WORK_ITEMS_FILTERS, + MODULE_WORK_ITEMS_COUNT_EVENT, + MODULE_WORK_ITEMS_DISPLAY_EVENT, + MODULE_WORK_ITEMS_FILTER_EVENT, + MODULE_WORK_ITEMS_OPEN_ADD_EXISTING_EVENT, + moduleWorkItemsPrefsKey, + parseModuleWorkItemsPrefs, + type ModuleWorkItemsDisplayState, + type ModuleWorkItemsFiltersState, +} from '../lib/moduleWorkItemsPrefs'; +import { + applyModuleSubWorkFilter, + filterModuleIssues, + sortModuleIssuesDefault, +} from '../lib/moduleWorkItemsApply'; const priorityVariant: Record = { urgent: 'danger', @@ -46,6 +65,25 @@ const IconCalendar = () => ( ); + +const IconCalendarClock = () => ( + + + + + + + +); + const IconUser = () => ( ( ); + const IconTag = () => ( ( ); -const IconMoreVertical = () => ( + +const IconEye = () => ( + + + + +); + +const IconModuleSmall = () => ( + + + + + + +); + +const IconEstimate = () => ( + + + + +); + +const IconPriorityNone = () => ( + + + + +); + +const IconMoreHorizontal = () => ( - + - + ); + const IconPlus = () => ( ( ); + const IconModule = () => ( + + + ); + } + return ( + + + + ); +} + export function ModuleDetailPage() { const { workspaceSlug, projectId, moduleId } = useParams<{ workspaceSlug: string; @@ -134,12 +253,19 @@ export function ModuleDetailPage() { const [issues, setIssues] = useState([]); const [states, setStates] = useState([]); const [labels, setLabels] = useState([]); + const [cycles, setCycles] = useState([]); const [members, setMembers] = useState([]); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [createOpen, setCreateOpen] = useState(false); const [addExistingOpen, setAddExistingOpen] = useState(false); const [createError, setCreateError] = useState(null); + const [listFilters, setListFilters] = useState( + DEFAULT_MODULE_WORK_ITEMS_FILTERS, + ); + const [listDisplay, setListDisplay] = useState( + DEFAULT_MODULE_WORK_ITEMS_DISPLAY, + ); const createParam = searchParams.get('create') === '1'; @@ -149,6 +275,45 @@ export function ModuleDetailPage() { } }, [createParam, projectId]); + useLayoutEffect(() => { + const onFilter = (e: Event) => { + const d = (e as CustomEvent).detail; + if (d) setListFilters(d); + }; + const onDisplay = (e: Event) => { + const d = (e as CustomEvent).detail; + if (d) setListDisplay(d); + }; + window.addEventListener(MODULE_WORK_ITEMS_FILTER_EVENT, onFilter); + window.addEventListener(MODULE_WORK_ITEMS_DISPLAY_EVENT, onDisplay); + return () => { + window.removeEventListener(MODULE_WORK_ITEMS_FILTER_EVENT, onFilter); + window.removeEventListener(MODULE_WORK_ITEMS_DISPLAY_EVENT, onDisplay); + }; + }, []); + + useEffect(() => { + const onOpenAdd = () => setAddExistingOpen(true); + window.addEventListener(MODULE_WORK_ITEMS_OPEN_ADD_EXISTING_EVENT, onOpenAdd); + return () => window.removeEventListener(MODULE_WORK_ITEMS_OPEN_ADD_EXISTING_EVENT, onOpenAdd); + }, []); + + useEffect(() => { + if (!workspaceSlug || !projectId || !resolvedModuleId) return; + const raw = localStorage.getItem(moduleWorkItemsPrefsKey(workspaceSlug, projectId, resolvedModuleId)); + const parsed = parseModuleWorkItemsPrefs(raw); + if (parsed) { + setListFilters({ ...DEFAULT_MODULE_WORK_ITEMS_FILTERS, ...parsed.filters }); + setListDisplay({ ...DEFAULT_MODULE_WORK_ITEMS_DISPLAY, ...parsed.display }); + } + }, [workspaceSlug, projectId, resolvedModuleId]); + + useEffect(() => { + window.dispatchEvent( + new CustomEvent(MODULE_WORK_ITEMS_COUNT_EVENT, { detail: { count: issues.length } }), + ); + }, [issues.length]); + const handleCloseCreate = () => { setCreateOpen(false); setCreateError(null); @@ -180,10 +345,11 @@ export function ModuleDetailPage() { issueService.list(workspaceSlug, projectId, { limit: 1000 }), stateService.list(workspaceSlug, projectId), labelService.list(workspaceSlug, projectId), + cycleService.list(workspaceSlug, projectId), workspaceService.listMembers(workspaceSlug), projectService.list(workspaceSlug), ]) - .then(([w, p, mods, iss, st, lab, mem, proj]) => { + .then(([w, p, mods, iss, st, lab, cyc, mem, proj]) => { if (cancelled) return; setWorkspace(w ?? null); setProject(p ?? null); @@ -197,6 +363,7 @@ export function ModuleDetailPage() { setIssues((iss ?? []).filter((i) => i.module_ids?.includes(found?.id ?? ''))); setStates(st ?? []); setLabels(lab ?? []); + setCycles(cyc ?? []); setMembers(mem ?? []); setProjects(proj ?? []); }) @@ -209,6 +376,7 @@ export function ModuleDetailPage() { setIssues([]); setStates([]); setLabels([]); + setCycles([]); setMembers([]); setProjects([]); } @@ -260,7 +428,7 @@ export function ModuleDetailPage() { }; const getStateName = (stateId: string | null | undefined) => - stateId ? (states.find((s) => s.id === stateId)?.name ?? stateId) : '—'; + stateId ? (states.find((s) => s.id === stateId)?.name ?? stateId) : 'No state'; const getLabelNames = (labelIds: string[] = []) => labelIds .map((id) => labels.find((l) => l.id === id)?.name) @@ -275,6 +443,49 @@ export function ModuleDetailPage() { return { id: userId, name, avatarUrl }; }; + const stateOrderMap = useMemo(() => { + const m = new Map(); + for (const s of states) { + m.set(s.id, s.sort_order ?? 0); + } + return m; + }, [states]); + + const { visibleFlat, groupedSections } = useMemo(() => { + let base = applyModuleSubWorkFilter(issues, listDisplay); + base = filterModuleIssues(base, listFilters); + base = sortModuleIssuesDefault(base, stateOrderMap); + + if (!listDisplay.groupByState) { + return { visibleFlat: base, groupedSections: null as null }; + } + + const byState: Record = {}; + for (const s of states) { + byState[s.id] = []; + } + byState['__none__'] = []; + for (const issue of base) { + const sid = issue.state_id; + if (sid && states.some((s) => s.id === sid)) { + byState[sid].push(issue); + } else { + byState['__none__'].push(issue); + } + } + + const orderKeys: string[] = [...states.map((s) => s.id), '__none__']; + const sections = orderKeys + .map((key) => ({ + key, + title: key === '__none__' ? 'No state' : (states.find((s) => s.id === key)?.name ?? key), + items: byState[key] ?? [], + })) + .filter((sec) => sec.items.length > 0); + + return { visibleFlat: base, groupedSections: sections }; + }, [issues, listDisplay, listFilters, stateOrderMap, states]); + if (loading) { return (
@@ -299,21 +510,158 @@ export function ModuleDetailPage() { } const baseUrl = `/${workspace.slug}/projects/${project.id}`; - const displayId = (issue: IssueApiResponse) => - `${project.identifier ?? project.id.slice(0, 8)}-${issue.sequence_id ?? issue.id.slice(-4)}`; - - const issuesByState = states.reduce>((acc, s) => { - acc[s.id] = issues.filter((i) => (i.state_id ?? '') === s.id); - return acc; - }, {}); - const ungrouped = issues.filter((i) => !i.state_id || !states.some((s) => s.id === i.state_id)); - if (ungrouped.length > 0) { - issuesByState[''] = ungrouped; - } + + const issueDisplayId = (issue: IssueApiResponse): string => { + const ident = project.identifier ?? project.id.slice(0, 8); + if (issue.sequence_id != null && Number.isFinite(issue.sequence_id)) { + return `${ident}-${issue.sequence_id}`; + } + const compact = issue.id.replace(/-/g, '').slice(0, 8); + return `${ident}-${compact}`; + }; + + const cycleLabel = (issue: IssueApiResponse): string | null => { + const ids = issue.cycle_ids ?? []; + if (ids.length === 0) return null; + const c = cycles.find((x) => ids.includes(x.id)); + return c?.name ?? null; + }; + + const renderIssueRow = (issue: IssueApiResponse) => { + const primaryAssigneeId = issue.assignee_ids?.[0] ?? null; + const assignee = getUser(primaryAssigneeId); + const labelNames = getLabelNames(issue.label_ids ?? []); + const sn = getStateName(issue.state_id ?? undefined); + const pri = (issue.priority as Priority) ?? 'none'; + const cycle = cycleLabel(issue); + + return ( +
  • + + + {issueDisplayId(issue)} + {issue.name} + +
    + {listDisplay.showState ? ( + + + {sn} + + ) : null} + {listDisplay.showPriority ? ( + + {pri === 'none' ? ( + + ) : ( + + {pri[0] ?? pri} + + )} + + ) : null} + {listDisplay.showStartDate ? ( + + + + ) : null} + {listDisplay.showDueDate ? ( + + + + ) : null} + {listDisplay.showAssignee ? ( + + {assignee ? ( + + ) : ( + + )} + + ) : null} + {listDisplay.showModule ? ( + + + {module.name} + + ) : null} + + + + {listDisplay.showLabels ? ( + + {labelNames.length > 0 ? : } + + ) : null} + {cycle ? ( + + {cycle} + + ) : null} + {listDisplay.showVisibility ? ( + + + + ) : null} + +
    + +
  • + ); + }; return (
    - {/* Empty state */} {issues.length === 0 && (
    @@ -328,7 +676,7 @@ export function ModuleDetailPage() {
    -
    +
    @@ -344,7 +692,7 @@ export function ModuleDetailPage() {
    -
    +
    @@ -378,173 +726,87 @@ export function ModuleDetailPage() {
    )} - {/* Work items by state */} {issues.length > 0 && ( -
    - {states.map((state) => { - const stateIssues = issuesByState[state.id] ?? []; - if (stateIssues.length === 0) return null; - return ( -
    +
    +

    + -
    - - - - - {state.name} {stateIssues.length} - -
    -
      - {stateIssues.map((issue) => { - const primaryAssigneeId = issue.assignee_ids?.[0] ?? null; - const assignee = getUser(primaryAssigneeId); - const labelNames = getLabelNames(issue.label_ids ?? []); - return ( -
    • - - - - {displayId(issue)} - - {issue.name} - -
      - - {getStateName(issue.state_id ?? undefined)} - - - {issue.priority ?? '—'} - - - - - - {assignee ? ( - - ) : ( - - )} - - - - - -
      - -
    • - ); - })} -
    -

    - ); - })} - {(issuesByState['']?.length ?? 0) > 0 && ( -
    -
    - - No state {issuesByState[''].length} - + + + All work items {visibleFlat.length} + +

    +
    + + {visibleFlat.length === 0 ? ( +
    +

    No work items match your filters.

    +
    + ) : !listDisplay.groupByState ? ( +
    +
      {visibleFlat.map(renderIssueRow)}
    +
    + + +
    +
    + ) : ( +
    + {groupedSections?.map((sec) => ( +
    +
    + + + +

    + {sec.title} + {sec.items.length} +

    +
    +
    +
      {sec.items.map(renderIssueRow)}
    +
    +
    + ))} +
    +
    -
      - {issuesByState[''].map((issue) => { - const primaryAssigneeId = issue.assignee_ids?.[0] ?? null; - const assignee = getUser(primaryAssigneeId); - return ( -
    • - - - - {displayId(issue)} - - {issue.name} - -
      - {assignee ? ( - - ) : ( - - )} - -
      - -
    • - ); - })} -
    - +
    )} -
    - )} - - {issues.length > 0 && ( -
    - - -
    + )} Date: Wed, 25 Mar 2026 06:04:34 +0400 Subject: [PATCH 04/12] ref: run linters on touched files --- .../components/layout/ModuleDetailHeader.tsx | 107 ++++++++++++++---- ui/src/components/layout/PageHeader.tsx | 2 +- .../ModuleWorkItemsToolbarPanels.tsx | 21 ++-- ui/src/lib/moduleWorkItemsApply.ts | 5 +- ui/src/lib/moduleWorkItemsPrefs.ts | 4 +- ui/src/pages/ModuleDetailPage.tsx | 46 ++++++-- 6 files changed, 141 insertions(+), 44 deletions(-) diff --git a/ui/src/components/layout/ModuleDetailHeader.tsx b/ui/src/components/layout/ModuleDetailHeader.tsx index 9c95db57..18838d44 100644 --- a/ui/src/components/layout/ModuleDetailHeader.tsx +++ b/ui/src/components/layout/ModuleDetailHeader.tsx @@ -10,7 +10,11 @@ import { } from '../module-work-items/ModuleWorkItemsToolbarPanels'; import { workspaceService } from '../../services/workspaceService'; import { stateService } from '../../services/stateService'; -import type { ProjectApiResponse, StateApiResponse, WorkspaceMemberApiResponse } from '../../api/types'; +import type { + ProjectApiResponse, + StateApiResponse, + WorkspaceMemberApiResponse, +} from '../../api/types'; import { DEFAULT_MODULE_WORK_ITEMS_DISPLAY, DEFAULT_MODULE_WORK_ITEMS_FILTERS, @@ -42,7 +46,15 @@ const IconChevronDown = () => ( ); const IconList = () => ( - + @@ -53,7 +65,15 @@ const IconList = () => ( ); const IconKanban = () => ( - + @@ -80,7 +100,15 @@ const IconCalendar = () => ( ); const IconSpreadsheet = () => ( - + @@ -89,7 +117,15 @@ const IconSpreadsheet = () => ( ); const IconGantt = () => ( - + @@ -100,13 +136,29 @@ const IconGantt = () => ( ); const IconFilter = () => ( - + ); const IconSliders = () => ( - + @@ -120,7 +172,15 @@ const IconSliders = () => ( ); const IconPlus = () => ( - + @@ -167,8 +227,12 @@ export function ModuleDetailHeader({ const [states, setStates] = useState([]); const [members, setMembers] = useState([]); - const [filters, setFilters] = useState(DEFAULT_MODULE_WORK_ITEMS_FILTERS); - const [display, setDisplay] = useState(DEFAULT_MODULE_WORK_ITEMS_DISPLAY); + const [filters, setFilters] = useState( + DEFAULT_MODULE_WORK_ITEMS_FILTERS, + ); + const [display, setDisplay] = useState( + DEFAULT_MODULE_WORK_ITEMS_DISPLAY, + ); const [dateModal, setDateModal] = useState<'due' | 'start' | null>(null); useEffect(() => { @@ -209,13 +273,15 @@ export function ModuleDetailHeader({ const key = moduleWorkItemsPrefsKey(workspaceSlug, projectId, moduleId); const raw = localStorage.getItem(key); const parsed = parseModuleWorkItemsPrefs(raw); - if (parsed) { - setFilters({ ...DEFAULT_MODULE_WORK_ITEMS_FILTERS, ...parsed.filters }); - setDisplay({ ...DEFAULT_MODULE_WORK_ITEMS_DISPLAY, ...parsed.display }); - } else { - setFilters(DEFAULT_MODULE_WORK_ITEMS_FILTERS); - setDisplay(DEFAULT_MODULE_WORK_ITEMS_DISPLAY); - } + queueMicrotask(() => { + if (parsed) { + setFilters({ ...DEFAULT_MODULE_WORK_ITEMS_FILTERS, ...parsed.filters }); + setDisplay({ ...DEFAULT_MODULE_WORK_ITEMS_DISPLAY, ...parsed.display }); + } else { + setFilters(DEFAULT_MODULE_WORK_ITEMS_FILTERS); + setDisplay(DEFAULT_MODULE_WORK_ITEMS_DISPLAY); + } + }); }, [workspaceSlug, projectId, moduleId]); useEffect(() => { @@ -287,7 +353,7 @@ export function ModuleDetailHeader({ {moduleName} @@ -377,7 +443,10 @@ export function ModuleDetailHeader({ {filtersActive ? ( - + ) : null} } diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index f60f2ade..5cd35523 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -2895,7 +2895,7 @@ export function PageHeader() { const base = workspaceSlug && projectId ? `/${workspaceSlug}/projects/${projectId}` : ''; const norm = pathname.replace(/\/+$/, '') || pathname; const onModuleDetail = Boolean(base && moduleId && norm === `${base}/modules/${moduleId}`); - if (!onModuleDetail) setModuleWorkItemCount(null); + if (!onModuleDetail) queueMicrotask(() => setModuleWorkItemCount(null)); }, [pathname, workspaceSlug, projectId, moduleId]); // Match route patterns to pick header diff --git a/ui/src/components/module-work-items/ModuleWorkItemsToolbarPanels.tsx b/ui/src/components/module-work-items/ModuleWorkItemsToolbarPanels.tsx index baa901a9..9c5a747e 100644 --- a/ui/src/components/module-work-items/ModuleWorkItemsToolbarPanels.tsx +++ b/ui/src/components/module-work-items/ModuleWorkItemsToolbarPanels.tsx @@ -75,11 +75,7 @@ export function ModuleWorkItemsFiltersPanel({ return (
    - toggle('priority')} - > + toggle('priority')}>
    {PRIORITIES.map((p) => { const on = filters.priorityKeys.includes(p); @@ -129,14 +125,16 @@ export function ModuleWorkItemsFiltersPanel({ ))}
    - toggle('assignee')}> + toggle('assignee')} + >
    {members.map((m) => { const id = m.member_id; const label = - m.member_display_name?.trim() || - m.member_email?.split('@')[0]?.trim() || - 'Member'; + m.member_display_name?.trim() || m.member_email?.split('@')[0]?.trim() || 'Member'; const on = filters.assigneeMemberIds.includes(id); return (
    ) : !listDisplay.groupByState ? (
    -
      {visibleFlat.map(renderIssueRow)}
    +
      + {visibleFlat.map(renderIssueRow)} +
    -
      {sec.items.map(renderIssueRow)}
    +
      + {sec.items.map(renderIssueRow)} +
    ))} From 1c2d90beabf1b41a9df8f7f3288204a68641fdd1 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Wed, 25 Mar 2026 06:16:16 +0400 Subject: [PATCH 05/12] [REFACTOR] Update layout, module-work-items, project-issues, lib (+1 more) for ui --- .../components/layout/ModuleDetailHeader.tsx | 22 +- .../ModuleWorkItemsToolbarPanels.tsx | 105 +---- .../ProjectIssuesDisplayPanel.tsx | 168 +++---- ui/src/lib/moduleWorkItemsApply.ts | 23 +- ui/src/lib/moduleWorkItemsPrefs.ts | 65 +-- ui/src/pages/ModuleDetailPage.tsx | 425 ++++++++---------- 6 files changed, 337 insertions(+), 471 deletions(-) diff --git a/ui/src/components/layout/ModuleDetailHeader.tsx b/ui/src/components/layout/ModuleDetailHeader.tsx index 18838d44..05038f2b 100644 --- a/ui/src/components/layout/ModuleDetailHeader.tsx +++ b/ui/src/components/layout/ModuleDetailHeader.tsx @@ -4,10 +4,8 @@ import { Button } from '../ui'; import { Dropdown } from '../work-item'; import { DateRangeModal } from '../workspace-views/DateRangeModal'; import { ProjectIconDisplay } from '../ProjectIconModal'; -import { - ModuleWorkItemsDisplayPanel, - ModuleWorkItemsFiltersPanel, -} from '../module-work-items/ModuleWorkItemsToolbarPanels'; +import { ModuleWorkItemsFiltersPanel } from '../module-work-items/ModuleWorkItemsToolbarPanels'; +import { ProjectIssuesDisplayPanel } from '../project-issues/ProjectIssuesDisplayPanel'; import { workspaceService } from '../../services/workspaceService'; import { stateService } from '../../services/stateService'; import type { @@ -16,7 +14,6 @@ import type { WorkspaceMemberApiResponse, } from '../../api/types'; import { - DEFAULT_MODULE_WORK_ITEMS_DISPLAY, DEFAULT_MODULE_WORK_ITEMS_FILTERS, MODULE_WORK_ITEMS_DISPLAY_EVENT, MODULE_WORK_ITEMS_FILTER_EVENT, @@ -25,9 +22,12 @@ import { moduleWorkItemsPrefsKey, parseModuleWorkItemsPrefs, serializeModuleWorkItemsPrefs, - type ModuleWorkItemsDisplayState, type ModuleWorkItemsFiltersState, } from '../../lib/moduleWorkItemsPrefs'; +import { + cloneDefaultProjectIssuesDisplay, + type ProjectIssuesDisplayState, +} from '../../lib/projectIssuesDisplay'; const IconChevronDown = () => ( ( DEFAULT_MODULE_WORK_ITEMS_FILTERS, ); - const [display, setDisplay] = useState( - DEFAULT_MODULE_WORK_ITEMS_DISPLAY, + const [display, setDisplay] = useState(() => + cloneDefaultProjectIssuesDisplay(), ); const [dateModal, setDateModal] = useState<'due' | 'start' | null>(null); @@ -276,10 +276,10 @@ export function ModuleDetailHeader({ queueMicrotask(() => { if (parsed) { setFilters({ ...DEFAULT_MODULE_WORK_ITEMS_FILTERS, ...parsed.filters }); - setDisplay({ ...DEFAULT_MODULE_WORK_ITEMS_DISPLAY, ...parsed.display }); + setDisplay(parsed.display); } else { setFilters(DEFAULT_MODULE_WORK_ITEMS_FILTERS); - setDisplay(DEFAULT_MODULE_WORK_ITEMS_DISPLAY); + setDisplay(cloneDefaultProjectIssuesDisplay()); } }); }, [workspaceSlug, projectId, moduleId]); @@ -481,7 +481,7 @@ export function ModuleDetailHeader({ } > - + ); } - -export interface ModuleWorkItemsDisplayPanelProps { - display: ModuleWorkItemsDisplayState; - setDisplay: Dispatch>; -} - -export function ModuleWorkItemsDisplayPanel({ - display, - setDisplay, -}: ModuleWorkItemsDisplayPanelProps) { - const [open, setOpen] = useState({ layout: true, props: true }); - const toggle = (key: keyof typeof open) => setOpen((o) => ({ ...o, [key]: !o[key] })); - - const rowToggle = (key: keyof ModuleWorkItemsDisplayState) => { - setDisplay((p) => ({ ...p, [key]: !p[key] })); - }; - - return ( -
    - toggle('layout')}> -
    - {( - [ - [false, 'All work items'], - [true, 'Group by state'], - ] as const - ).map(([val, label]) => ( - - ))} - -
    -
    - toggle('props')}> -
    - {( - [ - ['showState', 'State'], - ['showPriority', 'Priority'], - ['showStartDate', 'Start date'], - ['showDueDate', 'Due date'], - ['showAssignee', 'Assignees'], - ['showModule', 'Module'], - ['showLabels', 'Labels'], - ['showVisibility', 'Visibility'], - ] as const - ).map(([k, label]) => ( - - ))} -
    -
    -
    - ); -} diff --git a/ui/src/components/project-issues/ProjectIssuesDisplayPanel.tsx b/ui/src/components/project-issues/ProjectIssuesDisplayPanel.tsx index 41b7cac2..a7921f15 100644 --- a/ui/src/components/project-issues/ProjectIssuesDisplayPanel.tsx +++ b/ui/src/components/project-issues/ProjectIssuesDisplayPanel.tsx @@ -20,6 +20,20 @@ const IconChevronDown = () => ( ); +const IconChevronUp = () => ( + + + +); + const IconCheck = () => ( ( type SectionId = 'properties' | 'group' | 'order'; +/** Order matches the work-items Display reference. */ const GROUP_OPTIONS: { value: SavedViewGroupBy; label: string }[] = [ { value: 'states', label: 'States' }, { value: 'priority', label: 'Priority' }, @@ -69,13 +84,11 @@ function CollapsibleSection(props: { {title} - - + + {expanded ? : } {expanded ?
    {children}
    : null} @@ -94,7 +107,7 @@ function RadioRow(props: { - ); - })} -
    -
    - - -
    - {GROUP_OPTIONS.map((opt) => ( - setDisplay((p) => ({ ...p, groupBy: v }))} - /> - ))} -
    -
    - - -
    - {ORDER_OPTIONS.map((opt) => ( - setDisplay((p) => ({ ...p, orderBy: v }))} - /> - ))} -
    -
    -