From 62d76a396404ba85dff42b23247fa219f7103bfc Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Sun, 22 Mar 2026 13:39:16 +0400 Subject: [PATCH 1/3] outer box was removed:) --- ui/src/pages/IssueListPage.tsx | 50 ++++++++++++++++------------------ 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/ui/src/pages/IssueListPage.tsx b/ui/src/pages/IssueListPage.tsx index 07655c7b..c9048548 100644 --- a/ui/src/pages/IssueListPage.tsx +++ b/ui/src/pages/IssueListPage.tsx @@ -18,7 +18,7 @@ import type { WorkspaceMemberApiResponse, } from '../api/types'; import type { Priority } from '../types'; -import { getImageUrl } from '../lib/utils'; +import { findWorkspaceMemberByUserId, getImageUrl } from '../lib/utils'; const priorityVariant: Record = { urgent: 'danger', @@ -184,11 +184,13 @@ export function IssueListPage() { .filter((name): name is string => Boolean(name)); const getUser = (userId: string | null) => { if (!userId) return null; - const m = members.find((x) => x.member_id === userId); - const display = m?.member_display_name?.trim(); - const emailUser = m?.member_email?.split('@')[0]?.trim(); - const name = display || emailUser || 'Member'; - const avatarUrl = m?.member_avatar ?? null; + const m = findWorkspaceMemberByUserId(members, userId); + const display = m?.member_display_name?.trim() ?? ''; + const emailUser = m?.member_email?.trim().split('@')[0]?.trim() ?? ''; + const name = + display !== '' ? display : emailUser !== '' ? emailUser : userId.slice(0, 8); + const raw = m?.member_avatar?.trim(); + const avatarUrl = raw ? raw : null; return { id: userId, name, avatarUrl }; }; @@ -265,9 +267,9 @@ export function IssueListPage() { const baseUrl = `/${workspace.slug}/projects/${project.id}`; return ( -
- {/* All work items N + plus */} -
+
+ {/*header + list share the canvas (no outer card). */} +

All work items {issues.length}

- {/* List of work item rows */} -
- {issues.length === 0 ? ( -
-

No work items yet.

- -
- ) : ( -
    + {issues.length === 0 ? ( +
    +

    No work items yet.

    + +
    + ) : ( + <> +
      {issues.map((issue) => { const primaryAssigneeId = issue.assignee_ids && issue.assignee_ids.length > 0 ? issue.assignee_ids[0] : null; @@ -376,9 +377,6 @@ export function IssueListPage() { ); })}
    - )} - - {issues.length > 0 && (
    - )} -
+ + )} Date: Sun, 22 Mar 2026 14:07:01 +0400 Subject: [PATCH 2/3] feat: implemented filters button, dropped the extra inline maxHeight on the scroll region so height comes from the dropdown panel, filteredIssues uses the same rules as WorkspaceViewsPage, header count uses filteredIssues.length and empty state 'No work items match your filters.' when the list is non-empty but filtered to zero --- api/internal/queue/queue.go | 2 +- api/internal/redis/cache.go | 8 +- api/internal/service/cycle.go | 2 +- ui/src/components/layout/PageHeader.tsx | 152 ++++++- ui/src/components/layout/Sidebar.tsx | 4 +- .../ProjectIssuesFiltersPanel.tsx | 374 ++++++++++++++++++ ui/src/lib/projectIssuesEvents.ts | 28 ++ ui/src/lib/utils.ts | 9 +- ui/src/lib/viewFilterCount.ts | 2 +- ui/src/pages/CyclesPage.tsx | 2 +- ui/src/pages/IssueListPage.tsx | 142 ++++++- ui/src/pages/WorkspaceViewsPage.tsx | 6 +- ui/src/types/workspaceViewDisplay.ts | 6 +- 13 files changed, 697 insertions(+), 40 deletions(-) create mode 100644 ui/src/components/project-issues/ProjectIssuesFiltersPanel.tsx create mode 100644 ui/src/lib/projectIssuesEvents.ts diff --git a/api/internal/queue/queue.go b/api/internal/queue/queue.go index 544ac0ab..93361534 100644 --- a/api/internal/queue/queue.go +++ b/api/internal/queue/queue.go @@ -10,7 +10,7 @@ import ( amqp "github.com/rabbitmq/amqp091-go" ) -// Queue names (Plane-style: one queue per task type or shared). +// Queue names (one queue per task type or shared). const ( QueueEmails = "devlane.emails" QueueWebhooks = "devlane.webhooks" diff --git a/api/internal/redis/cache.go b/api/internal/redis/cache.go index 24ccd20c..5d9cedf6 100644 --- a/api/internal/redis/cache.go +++ b/api/internal/redis/cache.go @@ -8,7 +8,7 @@ import ( "github.com/redis/go-redis/v9" ) -// Cache key prefixes (Plane-style). +// Cache key prefixes. const ( PrefixMagicLink = "magic_" PrefixLock = "lock_" @@ -68,7 +68,7 @@ func (c *Client) CacheSet(ctx context.Context, key string, v interface{}, ttl ti return c.Client.Set(ctx, PrefixCache+key, data, ttl).Err() } -// --- Lock (distributed lock, Plane-style for batch tasks) --- +// --- Lock (distributed lock for batch tasks) --- // AcquireLock acquires a lock. Returns true if acquired, false if already held. func (c *Client) AcquireLock(ctx context.Context, lockID string, expire time.Duration) (bool, error) { @@ -85,7 +85,7 @@ func (c *Client) ReleaseLock(ctx context.Context, lockID string) error { return c.Client.Del(ctx, PrefixLock+lockID).Err() } -// --- Magic-link token (Plane-style: key = magic_, value = JSON) --- +// --- Magic-link token (key = magic_, value = JSON) --- // MagicLinkData is stored in Redis for magic-link auth. type MagicLinkData struct { @@ -128,7 +128,7 @@ func (c *Client) DeleteMagicLink(ctx context.Context, email string) error { return c.Client.Del(ctx, PrefixMagicLink+email).Err() } -// --- Short-lived metadata (e.g. request origin per issue, Plane-style) --- +// --- Short-lived metadata (e.g. request origin per issue) --- // SetRequestOrigin sets a short-lived value for an entity (e.g. issue_id -> origin). func (c *Client) SetRequestOrigin(ctx context.Context, entityID, origin string, ttl time.Duration) error { diff --git a/api/internal/service/cycle.go b/api/internal/service/cycle.go index 92d92dc7..288623f2 100644 --- a/api/internal/service/cycle.go +++ b/api/internal/service/cycle.go @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" ) -// computeCycleStatus derives status from start/end dates (Plane-style). +// computeCycleStatus derives status from start/end dates. // draft: no dates; current: now in range; upcoming: start > now; completed: end < now. func computeCycleStatus(start, end *time.Time) string { now := time.Now() diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index fc2a45a6..ddb5d74e 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -16,6 +16,8 @@ import { ProjectSavedViewMoreMenu } from '../project-saved-view/ProjectSavedView import { DateRangeModal } from '../workspace-views/DateRangeModal'; import { CreateModuleModal } from '../CreateModuleModal'; import { CreateCycleModal } from '../CreateCycleModal'; +import { ProjectIssuesFiltersPanel } from '../project-issues/ProjectIssuesFiltersPanel'; +import { useAuth } from '../../contexts/AuthContext'; import { workspaceService } from '../../services/workspaceService'; import { projectService } from '../../services/projectService'; import { issueService } from '../../services/issueService'; @@ -33,6 +35,11 @@ import { PROJECT_CYCLES_FILTER_EVENT, PROJECT_CYCLES_REFRESH_EVENT, } from '../../lib/projectCyclesEvents'; +import { + DEFAULT_PROJECT_ISSUES_FILTERS, + PROJECT_ISSUES_FILTER_EVENT, + type ProjectIssuesFiltersState, +} from '../../lib/projectIssuesEvents'; import { PROJECT_VIEWS_FILTER_EVENT } from '../../lib/projectViewsEvents'; export type ProjectSection = 'issues' | 'cycles' | 'modules' | 'views' | 'pages'; @@ -346,7 +353,7 @@ const IconLayers = () => ( ); -const IconViewsPlane = () => ( +const IconProjectViews = () => ( @@ -487,7 +494,7 @@ const SECTION_ICONS: Record = { issues: , cycles: , modules: , - views: , + views: , pages: , }; @@ -1011,6 +1018,7 @@ function ProjectSectionHeader({ issueCount: number; }) { const navigate = useNavigate(); + const { user: authUser } = useAuth(); const modulesFilter = useModulesFilter(); const { display: viewsDisplay, setDisplay } = useWorkspaceViewsState(); const baseUrl = `/${workspaceSlug}/projects/${projectId}`; @@ -1053,6 +1061,13 @@ function ProjectSectionHeader({ const [cyclesStartBefore, setCyclesStartBefore] = useState(null); const [cyclesDueAfter, setCyclesDueAfter] = useState(null); const [cyclesDueBefore, setCyclesDueBefore] = useState(null); + const [issuesFiltersOpen, setIssuesFiltersOpen] = useState(null); + const [issuesFiltersSearch, setIssuesFiltersSearch] = useState(''); + const [issuesMembers, setIssuesMembers] = useState([]); + const [issuesFilters, setIssuesFilters] = useState(() => ({ + ...DEFAULT_PROJECT_ISSUES_FILTERS, + })); + const [issuesDateRangeModal, setIssuesDateRangeModal] = useState<'start' | 'due' | null>(null); const projectDropdownRef = useRef(null); const modulesSearchInputRef = useRef(null); const cyclesSearchInputRef = useRef(null); @@ -1114,6 +1129,31 @@ function ProjectSectionHeader({ }; }, [section, workspaceSlug]); + useEffect(() => { + if (section !== 'issues') return; + let cancelled = false; + workspaceService + .listMembers(workspaceSlug) + .then((mem) => { + if (!cancelled) setIssuesMembers(mem ?? []); + }) + .catch(() => { + if (!cancelled) setIssuesMembers([]); + }); + return () => { + cancelled = true; + }; + }, [section, workspaceSlug]); + + useEffect(() => { + if (section !== 'issues' || !workspaceSlug || !projectId) return; + window.dispatchEvent( + new CustomEvent(PROJECT_ISSUES_FILTER_EVENT, { + detail: { workspaceSlug, projectId, filters: issuesFilters }, + }), + ); + }, [section, workspaceSlug, projectId, issuesFilters]); + const dispatchViewsFilters = ( next: Partial<{ query: string; @@ -1241,12 +1281,61 @@ function ProjectSectionHeader({
- +
+ } + displayValue="Filters" + panelClassName="flex w-[280px] max-h-[min(70vh,28rem)] flex-col rounded-md border border-(--border-subtle) bg-(--bg-surface-1) shadow-(--shadow-raised) overflow-hidden" + align="right" + triggerClassName="flex items-center gap-1.5 rounded-md border border-(--border-subtle) bg-(--bg-layer-2) px-2.5 py-1.5 text-[13px] font-medium text-(--txt-secondary) hover:bg-(--bg-layer-2-hover)" + triggerContent={ + <> + + + + Filters + + + + + } + > + { + setIssuesFiltersOpen(null); + setIssuesDateRangeModal('start'); + }} + onOpenCustomDue={() => { + setIssuesFiltersOpen(null); + setIssuesDateRangeModal('due'); + }} + /> + + {[ + issuesFilters.priorities.length, + issuesFilters.stateGroups.length, + issuesFilters.assigneeIds.length, + issuesFilters.startDate.length, + issuesFilters.dueDate.length, + ].some((n) => n > 0) && ( + + )} +
+ ) : filteredIssues.length === 0 ? ( +
+

No work items match your filters.

+
) : ( <>
    - {issues.map((issue) => { + {filteredIssues.map((issue) => { const primaryAssigneeId = issue.assignee_ids && issue.assignee_ids.length > 0 ? issue.assignee_ids[0] : null; const assignee = getUser(primaryAssigneeId); diff --git a/ui/src/pages/WorkspaceViewsPage.tsx b/ui/src/pages/WorkspaceViewsPage.tsx index 749eb509..88e58023 100644 --- a/ui/src/pages/WorkspaceViewsPage.tsx +++ b/ui/src/pages/WorkspaceViewsPage.tsx @@ -165,7 +165,7 @@ export function WorkspaceViewsPage() { const [loading, setLoading] = useState(true); const { user: currentUser } = useAuth(); - // When viewing a saved view, fetch it and apply its filters/display to state once (Plane-style; not in URL). + // When viewing a saved view, fetch it and apply its filters/display to state once (not driven by URL). useEffect(() => { if (prevViewIdRef.current !== viewId) { prevViewIdRef.current = viewId; @@ -319,7 +319,7 @@ export function WorkspaceViewsPage() { }); }); } - // Static view filters (Plane-style: assigned to me, created by me, subscribed) + // Static view filters: assigned to me, created by me, subscribed if (viewId === 'assigned' && currentUser?.id) { list = list.filter((i) => i.assignee_ids?.includes(currentUser.id)); } else if (viewId === 'created' && currentUser?.id) { @@ -536,7 +536,7 @@ export function WorkspaceViewsPage() { const baseUrl = `/${workspace.slug}`; - // Plane-style: fixed column order, scrollable from Priority onward + // Fixed column order, scrollable from Priority onward const scrollableColumns = SPREADSHEET_COLUMN_ORDER.filter( (k) => k === 'created_at' || diff --git a/ui/src/types/workspaceViewDisplay.ts b/ui/src/types/workspaceViewDisplay.ts index 57b49a25..bfb20b61 100644 --- a/ui/src/types/workspaceViewDisplay.ts +++ b/ui/src/types/workspaceViewDisplay.ts @@ -32,7 +32,7 @@ export const DISPLAY_PROPERTY_LABELS: Record = { cycle: 'Cycle', }; -/** Plane-style column order for spreadsheet view: Work items (sticky) then these (horizontal scroll). */ +/** Column order for spreadsheet view: Work items (sticky) then these (horizontal scroll). */ export const SPREADSHEET_COLUMN_ORDER: (DisplayPropertyKey | 'created_at' | 'updated_at')[] = [ 'priority', 'assignee', @@ -49,7 +49,7 @@ export const SPREADSHEET_COLUMN_ORDER: (DisplayPropertyKey | 'created_at' | 'upd 'sub_work_item_count', ]; -/** Plane-style layout options for workspace views. */ +/** Layout options for workspace views. */ export const VIEW_LAYOUTS = ['list', 'kanban', 'calendar', 'spreadsheet', 'gantt_chart'] as const; export type ViewLayout = (typeof VIEW_LAYOUTS)[number]; @@ -69,7 +69,7 @@ export interface WorkspaceViewDisplay { sortOrder: SortOrder; } -/** Sortable columns for workspace view table (Plane-style). */ +/** Sortable columns for workspace view table. */ export const SORTABLE_COLUMNS = [ 'name', 'created_at', From 8ad5f0ba07391a20552663777d56af78a60975e4 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Sun, 22 Mar 2026 14:38:01 +0400 Subject: [PATCH 3/3] feat: display button. Rows follow the saved view column model: optional ID, state, priority, start/due, assignee, labels, sub-work count, attachments, estimate, module, cycle, link, plus visibility and overflow menu --- ui/src/components/layout/PageHeader.tsx | 77 +++- .../ProjectIssuesDisplayPanel.tsx | 235 +++++++++++ ui/src/lib/issueListGroupAndSort.ts | 390 +++++++++++++++++ ui/src/lib/projectIssuesDisplay.ts | 143 +++++++ ui/src/lib/projectIssuesEvents.ts | 15 + ui/src/pages/IssueListPage.tsx | 391 ++++++++++++++---- 6 files changed, 1161 insertions(+), 90 deletions(-) create mode 100644 ui/src/components/project-issues/ProjectIssuesDisplayPanel.tsx create mode 100644 ui/src/lib/issueListGroupAndSort.ts create mode 100644 ui/src/lib/projectIssuesDisplay.ts diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index ddb5d74e..56391ef7 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -17,6 +17,7 @@ import { DateRangeModal } from '../workspace-views/DateRangeModal'; import { CreateModuleModal } from '../CreateModuleModal'; import { CreateCycleModal } from '../CreateCycleModal'; import { ProjectIssuesFiltersPanel } from '../project-issues/ProjectIssuesFiltersPanel'; +import { ProjectIssuesDisplayPanel } from '../project-issues/ProjectIssuesDisplayPanel'; import { useAuth } from '../../contexts/AuthContext'; import { workspaceService } from '../../services/workspaceService'; import { projectService } from '../../services/projectService'; @@ -37,9 +38,18 @@ import { } from '../../lib/projectCyclesEvents'; import { DEFAULT_PROJECT_ISSUES_FILTERS, + PROJECT_ISSUES_DISPLAY_EVENT, PROJECT_ISSUES_FILTER_EVENT, type ProjectIssuesFiltersState, } from '../../lib/projectIssuesEvents'; +import { + cloneDefaultProjectIssuesDisplay, + parseProjectIssuesDisplay, + projectIssuesDisplayStorageKey, + serializeProjectIssuesDisplay, + toDisplayPayload, + type ProjectIssuesDisplayState, +} from '../../lib/projectIssuesDisplay'; import { PROJECT_VIEWS_FILTER_EVENT } from '../../lib/projectViewsEvents'; export type ProjectSection = 'issues' | 'cycles' | 'modules' | 'views' | 'pages'; @@ -1068,6 +1078,10 @@ function ProjectSectionHeader({ ...DEFAULT_PROJECT_ISSUES_FILTERS, })); const [issuesDateRangeModal, setIssuesDateRangeModal] = useState<'start' | 'due' | null>(null); + const [issuesDisplayOpen, setIssuesDisplayOpen] = useState(null); + const [issuesDisplay, setIssuesDisplay] = useState(() => + cloneDefaultProjectIssuesDisplay(), + ); const projectDropdownRef = useRef(null); const modulesSearchInputRef = useRef(null); const cyclesSearchInputRef = useRef(null); @@ -1154,6 +1168,44 @@ function ProjectSectionHeader({ ); }, [section, workspaceSlug, projectId, issuesFilters]); + useEffect(() => { + if (section !== 'issues') return; + if (!workspaceSlug || !projectId) return; + const key = projectIssuesDisplayStorageKey(workspaceSlug, projectId); + try { + const raw = localStorage.getItem(key); + const parsed = parseProjectIssuesDisplay(raw); + queueMicrotask(() => setIssuesDisplay(parsed ?? cloneDefaultProjectIssuesDisplay())); + } catch { + queueMicrotask(() => setIssuesDisplay(cloneDefaultProjectIssuesDisplay())); + } + }, [section, workspaceSlug, projectId]); + + useEffect(() => { + if (section !== 'issues' || !workspaceSlug || !projectId) return; + try { + localStorage.setItem( + projectIssuesDisplayStorageKey(workspaceSlug, projectId), + serializeProjectIssuesDisplay(issuesDisplay), + ); + } catch { + // ignore quota / private mode + } + }, [section, workspaceSlug, projectId, issuesDisplay]); + + useEffect(() => { + if (section !== 'issues' || !workspaceSlug || !projectId) return; + window.dispatchEvent( + new CustomEvent(PROJECT_ISSUES_DISPLAY_EVENT, { + detail: { + workspaceSlug, + projectId, + display: toDisplayPayload(issuesDisplay), + }, + }), + ); + }, [section, workspaceSlug, projectId, issuesDisplay]); + const dispatchViewsFilters = ( next: Partial<{ query: string; @@ -1336,12 +1388,27 @@ function ProjectSectionHeader({ /> )}
- + + ( + + + +); + +const IconCheck = () => ( + + + +); + +type SectionId = 'properties' | 'group' | 'order'; + +const GROUP_OPTIONS: { value: SavedViewGroupBy; label: string }[] = [ + { value: 'states', label: 'States' }, + { value: 'priority', label: 'Priority' }, + { value: 'cycle', label: 'Cycle' }, + { value: 'module', label: 'Module' }, + { value: 'labels', label: 'Labels' }, + { value: 'assignees', label: 'Assignees' }, + { value: 'created_by', label: 'Created by' }, + { value: 'none', label: 'None' }, +]; + +const ORDER_OPTIONS: { value: SavedViewOrderBy; label: string }[] = [ + { value: 'manual', label: 'Manual' }, + { value: 'last_created', label: 'Last created' }, + { value: 'last_updated', label: 'Last updated' }, + { value: 'start_date', label: 'Start date' }, + { value: 'due_date', label: 'Due date' }, + { value: 'priority', label: 'Priority' }, +]; + +function CollapsibleSection(props: { + id: SectionId; + title: string; + expanded: boolean; + onToggle: (id: SectionId) => void; + children: ReactNode; +}) { + const { id, title, expanded, onToggle, children } = props; + return ( +
+ + {expanded ?
{children}
: null} +
+ ); +} + +function RadioRow(props: { + selected: boolean; + value: T; + label: string; + onSelect: (v: T) => void; +}) { + const { selected, value, label, onSelect } = props; + return ( + + ); +} + +export interface ProjectIssuesDisplayPanelProps { + display: ProjectIssuesDisplayState; + setDisplay: React.Dispatch>; +} + +export function ProjectIssuesDisplayPanel({ display, setDisplay }: ProjectIssuesDisplayPanelProps) { + const [sections, setSections] = useState>({ + properties: true, + group: true, + order: true, + }); + + const toggleSection = (id: SectionId) => { + setSections((s) => ({ ...s, [id]: !s[id] })); + }; + + const toggleProperty = (id: (typeof ALL_SAVED_VIEW_DISPLAY_PROPERTIES)[number]) => { + setDisplay((prev) => { + const next = new Set(prev.displayProperties); + if (next.has(id)) next.delete(id); + else next.add(id); + return { ...prev, displayProperties: next }; + }); + }; + + return ( +
+ +
+ {ALL_SAVED_VIEW_DISPLAY_PROPERTIES.map((prop) => { + const on = display.displayProperties.has(prop); + return ( + + ); + })} +
+
+ + +
+ {GROUP_OPTIONS.map((opt) => ( + setDisplay((p) => ({ ...p, groupBy: v }))} + /> + ))} +
+
+ + +
+ {ORDER_OPTIONS.map((opt) => ( + setDisplay((p) => ({ ...p, orderBy: v }))} + /> + ))} +
+
+ + + +
+ ); +} diff --git a/ui/src/lib/issueListGroupAndSort.ts b/ui/src/lib/issueListGroupAndSort.ts new file mode 100644 index 00000000..99e7f457 --- /dev/null +++ b/ui/src/lib/issueListGroupAndSort.ts @@ -0,0 +1,390 @@ +import type { + IssueApiResponse, + StateApiResponse, + LabelApiResponse, + CycleApiResponse, + ModuleApiResponse, + WorkspaceMemberApiResponse, +} from '../api/types'; +import type { SavedViewGroupBy, SavedViewOrderBy } from './projectSavedViewDisplay'; + +const NONE_STATE_KEY = '__no_state__'; +const ALL_GROUP_KEY = '__all__'; +const NONE_CYCLE_KEY = '__no_cycle__'; +const NONE_MODULE_KEY = '__no_module__'; +const NONE_LABEL_KEY = '__no_label__'; +const NONE_ASSIGNEE_KEY = '__no_assignee__'; +const NONE_CREATOR_KEY = '__no_creator__'; + +const PRIORITY_RANK: Record = { + urgent: 0, + high: 1, + medium: 2, + low: 3, + none: 4, +}; + +function pushUniq(arr: string[], id: string) { + if (!arr.includes(id)) arr.push(id); +} + +export function sortIssuesByOrder( + list: IssueApiResponse[], + orderBy: SavedViewOrderBy, +): IssueApiResponse[] { + const out = [...list]; + switch (orderBy) { + case 'manual': + return out.sort((a, b) => { + const so = (a.sort_order ?? 0) - (b.sort_order ?? 0); + if (so !== 0) return so; + const seq = (a.sequence_id ?? 0) - (b.sequence_id ?? 0); + if (seq !== 0) return seq; + return a.name.localeCompare(b.name); + }); + case 'last_created': + return out.sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); + case 'last_updated': + return out.sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + case 'start_date': + return out.sort((a, b) => { + const as = a.start_date ? new Date(a.start_date).getTime() : Infinity; + const bs = b.start_date ? new Date(b.start_date).getTime() : Infinity; + return as - bs; + }); + case 'due_date': + return out.sort((a, b) => { + const ad = a.target_date ? new Date(a.target_date).getTime() : Infinity; + const bd = b.target_date ? new Date(b.target_date).getTime() : Infinity; + return ad - bd; + }); + case 'priority': + return out.sort((a, b) => { + const pa = PRIORITY_RANK[a.priority ?? 'none'] ?? 99; + const pb = PRIORITY_RANK[b.priority ?? 'none'] ?? 99; + return pa - pb; + }); + default: + return out; + } +} + +export interface GroupedIssuesResult { + order: string[]; + groups: Map; + title: (key: string) => string; + isFlat: boolean; +} + +export function buildGroupedIssues(params: { + baseForGrouping: IssueApiResponse[]; + groupBy: SavedViewGroupBy; + orderBy: SavedViewOrderBy; + showEmptyGroups: boolean; + states: StateApiResponse[]; + cycles: CycleApiResponse[]; + modules: ModuleApiResponse[]; + labels: LabelApiResponse[]; + members: WorkspaceMemberApiResponse[]; +}): GroupedIssuesResult { + const { + baseForGrouping, + groupBy, + orderBy, + showEmptyGroups, + states, + cycles, + modules, + labels, + members, + } = params; + + const sortIn = (arr: IssueApiResponse[]) => sortIssuesByOrder([...arr], orderBy); + + const sortedStates = [...states].sort( + (a, b) => (a.sequence ?? 0) - (b.sequence ?? 0) || a.name.localeCompare(b.name), + ); + + const getStateName = (stateKey: string) => { + if (stateKey === NONE_STATE_KEY) return 'No state'; + return states.find((s) => s.id === stateKey)?.name ?? stateKey; + }; + + if (groupBy === 'none') { + const m = new Map(); + m.set(ALL_GROUP_KEY, sortIn(baseForGrouping)); + return { + order: [ALL_GROUP_KEY], + groups: m, + title: () => '', + isFlat: true, + }; + } + + if (groupBy === 'states') { + const map = new Map(); + for (const issue of baseForGrouping) { + const key = issue.state_id?.trim() ? issue.state_id : NONE_STATE_KEY; + const arr = map.get(key) ?? []; + arr.push(issue); + map.set(key, arr); + } + let ordered: string[]; + if (showEmptyGroups) { + ordered = sortedStates.map((s) => s.id); + ordered.push(NONE_STATE_KEY); + for (const k of ordered) { + if (!map.has(k)) map.set(k, []); + } + for (const id of map.keys()) pushUniq(ordered, id); + } else { + ordered = []; + const seen = new Set(); + for (const s of sortedStates) { + if (map.has(s.id)) { + ordered.push(s.id); + seen.add(s.id); + } + } + for (const id of map.keys()) { + if (!seen.has(id)) pushUniq(ordered, id); + } + } + for (const k of ordered) { + const arr = map.get(k); + if (arr) map.set(k, sortIn(arr)); + } + return { order: ordered, groups: map, title: getStateName, isFlat: false }; + } + + if (groupBy === 'priority') { + const map = new Map(); + for (const issue of baseForGrouping) { + const key = issue.priority?.trim() || 'none'; + const arr = map.get(key) ?? []; + arr.push(issue); + map.set(key, arr); + } + let ordered: string[]; + if (showEmptyGroups) { + ordered = ['urgent', 'high', 'medium', 'low', 'none']; + for (const k of ordered) { + if (!map.has(k)) map.set(k, []); + } + for (const id of map.keys()) pushUniq(ordered, id); + } else { + ordered = []; + for (const p of ['urgent', 'high', 'medium', 'low', 'none']) { + if (map.has(p)) ordered.push(p); + } + for (const id of map.keys()) pushUniq(ordered, id); + } + for (const k of ordered) { + const arr = map.get(k); + if (arr) map.set(k, sortIn(arr)); + } + return { + order: ordered, + groups: map, + title: (k) => (k === 'none' ? 'None' : k.charAt(0).toUpperCase() + k.slice(1)), + isFlat: false, + }; + } + + if (groupBy === 'cycle') { + const map = new Map(); + for (const issue of baseForGrouping) { + const cid = issue.cycle_ids?.[0]?.trim(); + const key = cid ?? NONE_CYCLE_KEY; + const arr = map.get(key) ?? []; + arr.push(issue); + map.set(key, arr); + } + const cyclesSorted = [...cycles].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); + let ordered: string[]; + if (showEmptyGroups) { + ordered = [...cyclesSorted.map((c) => c.id), NONE_CYCLE_KEY]; + for (const k of ordered) { + if (!map.has(k)) map.set(k, []); + } + for (const id of map.keys()) pushUniq(ordered, id); + } else { + ordered = []; + for (const c of cyclesSorted) { + if (map.has(c.id)) ordered.push(c.id); + } + for (const id of map.keys()) pushUniq(ordered, id); + } + for (const k of ordered) { + const arr = map.get(k); + if (arr) map.set(k, sortIn(arr)); + } + return { + order: ordered, + groups: map, + title: (k) => + k === NONE_CYCLE_KEY ? 'No cycle' : (cycles.find((c) => c.id === k)?.name ?? k), + isFlat: false, + }; + } + + if (groupBy === 'module') { + const map = new Map(); + for (const issue of baseForGrouping) { + const mid = issue.module_ids?.[0]?.trim(); + const key = mid ?? NONE_MODULE_KEY; + const arr = map.get(key) ?? []; + arr.push(issue); + map.set(key, arr); + } + const modulesSorted = [...modules].sort((a, b) => a.name.localeCompare(b.name)); + let ordered: string[]; + if (showEmptyGroups) { + ordered = [...modulesSorted.map((m) => m.id), NONE_MODULE_KEY]; + for (const k of ordered) { + if (!map.has(k)) map.set(k, []); + } + for (const id of map.keys()) pushUniq(ordered, id); + } else { + ordered = []; + for (const mod of modulesSorted) { + if (map.has(mod.id)) ordered.push(mod.id); + } + for (const id of map.keys()) pushUniq(ordered, id); + } + for (const k of ordered) { + const arr = map.get(k); + if (arr) map.set(k, sortIn(arr)); + } + return { + order: ordered, + groups: map, + title: (k) => + k === NONE_MODULE_KEY ? 'No module' : (modules.find((m) => m.id === k)?.name ?? k), + isFlat: false, + }; + } + + if (groupBy === 'labels') { + const firstLabel = (issue: IssueApiResponse) => { + const ids = [...(issue.label_ids ?? [])].sort((a, b) => { + const na = labels.find((l) => l.id === a)?.name ?? a; + const nb = labels.find((l) => l.id === b)?.name ?? b; + return na.localeCompare(nb); + }); + return ids[0] ?? NONE_LABEL_KEY; + }; + const map = new Map(); + for (const issue of baseForGrouping) { + const key = firstLabel(issue); + const arr = map.get(key) ?? []; + arr.push(issue); + map.set(key, arr); + } + const labelOrder = [...labels].sort((a, b) => a.name.localeCompare(b.name)).map((l) => l.id); + let ordered: string[]; + if (showEmptyGroups) { + ordered = [...labelOrder, NONE_LABEL_KEY]; + for (const k of ordered) { + if (!map.has(k)) map.set(k, []); + } + for (const id of map.keys()) pushUniq(ordered, id); + } else { + ordered = []; + for (const id of labelOrder) { + if (map.has(id)) ordered.push(id); + } + for (const id of map.keys()) pushUniq(ordered, id); + } + for (const k of ordered) { + const arr = map.get(k); + if (arr) map.set(k, sortIn(arr)); + } + return { + order: ordered, + groups: map, + title: (k) => + k === NONE_LABEL_KEY ? 'No labels' : (labels.find((l) => l.id === k)?.name ?? k), + isFlat: false, + }; + } + + if (groupBy === 'assignees') { + const map = new Map(); + for (const issue of baseForGrouping) { + const aid = issue.assignee_ids?.[0]?.trim(); + const key = aid ?? NONE_ASSIGNEE_KEY; + const arr = map.get(key) ?? []; + arr.push(issue); + map.set(key, arr); + } + if (showEmptyGroups && !map.has(NONE_ASSIGNEE_KEY)) { + map.set(NONE_ASSIGNEE_KEY, []); + } + const memberName = (uid: string) => { + const m = members.find((x) => x.member_id === uid); + return m?.member_display_name?.trim() || m?.member_email?.split('@')[0]?.trim() || 'Member'; + }; + const ordered: string[] = []; + const ids = [...map.keys()].filter((k) => k !== NONE_ASSIGNEE_KEY); + ids.sort((a, b) => memberName(a).localeCompare(memberName(b))); + for (const id of ids) ordered.push(id); + if (map.has(NONE_ASSIGNEE_KEY)) ordered.push(NONE_ASSIGNEE_KEY); + for (const k of ordered) { + const arr = map.get(k); + if (arr) map.set(k, sortIn(arr)); + } + return { + order: ordered, + groups: map, + title: (k) => (k === NONE_ASSIGNEE_KEY ? 'Unassigned' : memberName(k)), + isFlat: false, + }; + } + + if (groupBy === 'created_by') { + const map = new Map(); + for (const issue of baseForGrouping) { + const uid = issue.created_by_id?.trim(); + const key = uid ?? NONE_CREATOR_KEY; + const arr = map.get(key) ?? []; + arr.push(issue); + map.set(key, arr); + } + if (showEmptyGroups && !map.has(NONE_CREATOR_KEY)) { + map.set(NONE_CREATOR_KEY, []); + } + const memberName = (uid: string) => { + const m = members.find((x) => x.member_id === uid); + return m?.member_display_name?.trim() || m?.member_email?.split('@')[0]?.trim() || 'Member'; + }; + const ordered: string[] = []; + const ids = [...map.keys()].filter((k) => k !== NONE_CREATOR_KEY); + ids.sort((a, b) => memberName(a).localeCompare(memberName(b))); + for (const id of ids) ordered.push(id); + if (map.has(NONE_CREATOR_KEY)) ordered.push(NONE_CREATOR_KEY); + for (const k of ordered) { + const arr = map.get(k); + if (arr) map.set(k, sortIn(arr)); + } + return { + order: ordered, + groups: map, + title: (k) => (k === NONE_CREATOR_KEY ? 'Unknown' : memberName(k)), + isFlat: false, + }; + } + + const m = new Map(); + m.set(ALL_GROUP_KEY, sortIn(baseForGrouping)); + return { + order: [ALL_GROUP_KEY], + groups: m, + title: () => '', + isFlat: true, + }; +} diff --git a/ui/src/lib/projectIssuesDisplay.ts b/ui/src/lib/projectIssuesDisplay.ts new file mode 100644 index 00000000..d813111c --- /dev/null +++ b/ui/src/lib/projectIssuesDisplay.ts @@ -0,0 +1,143 @@ +import { + ALL_SAVED_VIEW_DISPLAY_PROPERTIES, + type SavedViewDisplayPropertyId, + type SavedViewGroupBy, + type SavedViewOrderBy, +} from './projectSavedViewDisplay'; +import type { ProjectIssuesDisplayPayload } from './projectIssuesEvents'; + +export type { + SavedViewDisplayPropertyId, + SavedViewGroupBy, + SavedViewOrderBy, +} from './projectSavedViewDisplay'; + +export { SAVED_VIEW_DISPLAY_PROPERTY_LABELS } from './projectSavedViewDisplay'; + +const GROUP_BY_OPTIONS: SavedViewGroupBy[] = [ + 'states', + 'priority', + 'cycle', + 'module', + 'labels', + 'assignees', + 'created_by', + 'none', +]; + +const ORDER_BY_OPTIONS: SavedViewOrderBy[] = [ + 'manual', + 'last_created', + 'last_updated', + 'start_date', + 'due_date', + 'priority', +]; + +export interface ProjectIssuesDisplayState { + displayProperties: Set; + groupBy: SavedViewGroupBy; + orderBy: SavedViewOrderBy; + showSubWorkItems: boolean; + showEmptyGroups: boolean; +} + +export const DEFAULT_PROJECT_ISSUES_DISPLAY: ProjectIssuesDisplayState = { + displayProperties: new Set(ALL_SAVED_VIEW_DISPLAY_PROPERTIES), + groupBy: 'none', + orderBy: 'last_created', + showSubWorkItems: true, + showEmptyGroups: true, +}; + +export function cloneDefaultProjectIssuesDisplay(): ProjectIssuesDisplayState { + return { + displayProperties: new Set(DEFAULT_PROJECT_ISSUES_DISPLAY.displayProperties), + groupBy: DEFAULT_PROJECT_ISSUES_DISPLAY.groupBy, + orderBy: DEFAULT_PROJECT_ISSUES_DISPLAY.orderBy, + showSubWorkItems: DEFAULT_PROJECT_ISSUES_DISPLAY.showSubWorkItems, + showEmptyGroups: DEFAULT_PROJECT_ISSUES_DISPLAY.showEmptyGroups, + }; +} + +function isValidPropertyId(x: string): x is SavedViewDisplayPropertyId { + return (ALL_SAVED_VIEW_DISPLAY_PROPERTIES as string[]).includes(x); +} + +export interface PersistedProjectIssuesDisplay { + displayProperties: string[]; + groupBy: string; + orderBy: string; + showSubWorkItems: boolean; + showEmptyGroups?: boolean; +} + +export function parseProjectIssuesDisplay(raw: string | null): ProjectIssuesDisplayState | null { + if (!raw) return null; + try { + const p = JSON.parse(raw) as PersistedProjectIssuesDisplay; + const props = new Set(); + if (Array.isArray(p.displayProperties)) { + for (const id of p.displayProperties) { + if (typeof id === 'string' && isValidPropertyId(id)) props.add(id); + } + } + const groupBy = GROUP_BY_OPTIONS.includes(p.groupBy as SavedViewGroupBy) + ? (p.groupBy as SavedViewGroupBy) + : DEFAULT_PROJECT_ISSUES_DISPLAY.groupBy; + const orderBy = ORDER_BY_OPTIONS.includes(p.orderBy as SavedViewOrderBy) + ? (p.orderBy as SavedViewOrderBy) + : DEFAULT_PROJECT_ISSUES_DISPLAY.orderBy; + return { + displayProperties: props.size > 0 ? props : new Set(ALL_SAVED_VIEW_DISPLAY_PROPERTIES), + groupBy, + orderBy, + showSubWorkItems: p.showSubWorkItems !== undefined ? Boolean(p.showSubWorkItems) : true, + showEmptyGroups: p.showEmptyGroups !== undefined ? Boolean(p.showEmptyGroups) : true, + }; + } catch { + return null; + } +} + +export function serializeProjectIssuesDisplay(s: ProjectIssuesDisplayState): string { + return JSON.stringify({ + displayProperties: [...s.displayProperties], + groupBy: s.groupBy, + orderBy: s.orderBy, + showSubWorkItems: s.showSubWorkItems, + showEmptyGroups: s.showEmptyGroups, + }); +} + +export function projectIssuesDisplayStorageKey(workspaceSlug: string, projectId: string): string { + return `devlane:project-issues-display:${workspaceSlug}:${projectId}`; +} + +export function toDisplayPayload(s: ProjectIssuesDisplayState): ProjectIssuesDisplayPayload { + return { + displayProperties: [...s.displayProperties], + groupBy: s.groupBy, + orderBy: s.orderBy, + showSubWorkItems: s.showSubWorkItems, + showEmptyGroups: s.showEmptyGroups, + }; +} + +export function fromDisplayPayload(p: ProjectIssuesDisplayPayload): ProjectIssuesDisplayState { + const props = new Set(); + for (const id of p.displayProperties) { + if (isValidPropertyId(id)) props.add(id); + } + return { + displayProperties: props.size > 0 ? props : new Set(ALL_SAVED_VIEW_DISPLAY_PROPERTIES), + groupBy: GROUP_BY_OPTIONS.includes(p.groupBy) + ? p.groupBy + : DEFAULT_PROJECT_ISSUES_DISPLAY.groupBy, + orderBy: ORDER_BY_OPTIONS.includes(p.orderBy) + ? p.orderBy + : DEFAULT_PROJECT_ISSUES_DISPLAY.orderBy, + showSubWorkItems: p.showSubWorkItems, + showEmptyGroups: p.showEmptyGroups, + }; +} diff --git a/ui/src/lib/projectIssuesEvents.ts b/ui/src/lib/projectIssuesEvents.ts index 58004c83..a8d5e63e 100644 --- a/ui/src/lib/projectIssuesEvents.ts +++ b/ui/src/lib/projectIssuesEvents.ts @@ -1,8 +1,23 @@ import type { DatePreset, Priority, StateGroup } from '../types/workspaceViewFilters'; +import type { + SavedViewDisplayPropertyId, + SavedViewGroupBy, + SavedViewOrderBy, +} from './projectSavedViewDisplay'; /** Dispatched from PageHeader when project work-items filters change; IssueListPage listens. */ export const PROJECT_ISSUES_FILTER_EVENT = 'project-issues-filter-change'; +export const PROJECT_ISSUES_DISPLAY_EVENT = 'project-issues-display-change'; + +export interface ProjectIssuesDisplayPayload { + displayProperties: SavedViewDisplayPropertyId[]; + groupBy: SavedViewGroupBy; + orderBy: SavedViewOrderBy; + showSubWorkItems: boolean; + showEmptyGroups: boolean; +} + export interface ProjectIssuesFiltersState { priorities: Priority[]; stateGroups: StateGroup[]; diff --git a/ui/src/pages/IssueListPage.tsx b/ui/src/pages/IssueListPage.tsx index 6f2a5852..d92f8be8 100644 --- a/ui/src/pages/IssueListPage.tsx +++ b/ui/src/pages/IssueListPage.tsx @@ -16,12 +16,23 @@ import type { StateApiResponse, LabelApiResponse, WorkspaceMemberApiResponse, + CycleApiResponse, + ModuleApiResponse, } from '../api/types'; import type { Priority } from '../types'; import type { StateGroup } from '../types/workspaceViewFilters'; +import type { SavedViewDisplayPropertyId } from '../lib/projectSavedViewDisplay'; +import { buildGroupedIssues } from '../lib/issueListGroupAndSort'; +import { + cloneDefaultProjectIssuesDisplay, + fromDisplayPayload, + type ProjectIssuesDisplayState, +} from '../lib/projectIssuesDisplay'; import { DEFAULT_PROJECT_ISSUES_FILTERS, + PROJECT_ISSUES_DISPLAY_EVENT, PROJECT_ISSUES_FILTER_EVENT, + type ProjectIssuesDisplayPayload, type ProjectIssuesFiltersState, } from '../lib/projectIssuesEvents'; import { findWorkspaceMemberByUserId, getImageUrl, normalizeUuidKey } from '../lib/utils'; @@ -113,6 +124,29 @@ const IconPlus = () => ( ); +const IconLinkOut = () => ( + + + + + +); + +function formatShortDate(iso: string | null | undefined): string | null { + if (!iso?.trim()) return null; + const t = Date.parse(iso); + if (Number.isNaN(t)) return null; + return new Date(t).toLocaleDateString(); +} + export function IssueListPage() { const { workspaceSlug, projectId } = useParams<{ workspaceSlug: string; @@ -126,12 +160,17 @@ export function IssueListPage() { const [issues, setIssues] = useState([]); const [states, setStates] = useState([]); const [labels, setLabels] = useState([]); + const [cycles, setCycles] = useState([]); + const [modules, setModules] = useState([]); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [createError, setCreateError] = useState(null); const [listFilters, setListFilters] = useState(() => ({ ...DEFAULT_PROJECT_ISSUES_FILTERS, })); + const [listDisplay, setListDisplay] = useState(() => + cloneDefaultProjectIssuesDisplay(), + ); const refetchIssues = () => { if (!workspaceSlug || !projectId) return; @@ -156,9 +195,11 @@ export function IssueListPage() { issueService.list(workspaceSlug, projectId, { limit: 100 }), stateService.list(workspaceSlug, projectId), labelService.list(workspaceSlug, projectId), + cycleService.list(workspaceSlug, projectId), + moduleService.list(workspaceSlug, projectId), workspaceService.listMembers(workspaceSlug), ]) - .then(([w, p, list, iss, st, lab, mem]) => { + .then(([w, p, list, iss, st, lab, cyc, mod, mem]) => { if (cancelled) return; setWorkspace(w); setProject(p); @@ -166,6 +207,8 @@ export function IssueListPage() { setIssues(iss ?? []); setStates(st ?? []); setLabels(lab ?? []); + setCycles(cyc ?? []); + setModules(mod ?? []); setMembers(mem ?? []); }) .catch(() => { @@ -175,6 +218,8 @@ export function IssueListPage() { setIssues([]); setStates([]); setLabels([]); + setCycles([]); + setModules([]); setMembers([]); }) .finally(() => { @@ -200,6 +245,21 @@ export function IssueListPage() { return () => window.removeEventListener(PROJECT_ISSUES_FILTER_EVENT, handler); }, [workspaceSlug, projectId]); + useLayoutEffect(() => { + const handler = (e: Event) => { + const ce = e as CustomEvent<{ + workspaceSlug: string; + projectId: string; + display: ProjectIssuesDisplayPayload; + }>; + const d = ce.detail; + if (!d || d.workspaceSlug !== workspaceSlug || d.projectId !== projectId) return; + setListDisplay(fromDisplayPayload(d.display)); + }; + window.addEventListener(PROJECT_ISSUES_DISPLAY_EVENT, handler); + return () => window.removeEventListener(PROJECT_ISSUES_DISPLAY_EVENT, handler); + }, [workspaceSlug, projectId]); + const filteredIssues = useMemo(() => { const stateGroupMap: Record = { backlog: 'backlog', @@ -303,6 +363,50 @@ export function IssueListPage() { return list; }, [issues, states, listFilters]); + const subWorkCountByParentId = useMemo(() => { + const m = new Map(); + for (const i of issues) { + const pid = i.parent_id?.trim(); + if (!pid) continue; + m.set(pid, (m.get(pid) ?? 0) + 1); + } + return m; + }, [issues]); + + const baseForGrouping = useMemo(() => { + let list = filteredIssues; + if (!listDisplay.showSubWorkItems) { + list = list.filter((i) => !i.parent_id?.trim()); + } + return list; + }, [filteredIssues, listDisplay.showSubWorkItems]); + + const groupedIssues = useMemo( + () => + buildGroupedIssues({ + baseForGrouping, + groupBy: listDisplay.groupBy, + orderBy: listDisplay.orderBy, + showEmptyGroups: listDisplay.showEmptyGroups, + states, + cycles, + modules, + labels, + members, + }), + [ + baseForGrouping, + listDisplay.groupBy, + listDisplay.orderBy, + listDisplay.showEmptyGroups, + states, + cycles, + modules, + labels, + members, + ], + ); + const getStateName = (stateId: string | null | undefined) => stateId ? (states.find((s) => s.id === stateId)?.name ?? stateId) : '—'; const getLabelNames = (labelIds: string[] = []) => @@ -391,10 +495,183 @@ export function IssueListPage() { } const baseUrl = `/${workspace.slug}/projects/${project.id}`; + const dp = listDisplay.displayProperties; + const hasCol = (id: SavedViewDisplayPropertyId) => dp.has(id); + + const cycleName = (issue: IssueApiResponse) => { + const id = issue.cycle_ids?.[0]; + return id ? (cycles.find((c) => c.id === id)?.name ?? '—') : '—'; + }; + + const moduleName = (issue: IssueApiResponse) => { + const id = issue.module_ids?.[0]; + return id ? (modules.find((m) => m.id === id)?.name ?? '—') : '—'; + }; + + const renderIssueRow = (issue: IssueApiResponse) => { + const primaryAssigneeId = + issue.assignee_ids && issue.assignee_ids.length > 0 ? issue.assignee_ids[0] : null; + const assignee = getUser(primaryAssigneeId); + const labelNames = getLabelNames(issue.label_ids ?? []); + const displayId = `${project.identifier ?? project.id.slice(0, 8)}-${issue.sequence_id ?? issue.id.slice(-4)}`; + const startStr = formatShortDate(issue.start_date); + const dueStr = formatShortDate(issue.target_date); + const subN = subWorkCountByParentId.get(issue.id) ?? 0; + const issueUrl = `${baseUrl}/issues/${issue.id}`; + + return ( +
  • + + + {hasCol('id') ? ( + <> + {displayId} + {issue.name} + + ) : ( + {issue.name} + )} + +
    + {hasCol('state') ? ( + + + {getStateName(issue.state_id ?? undefined)} + + + ) : null} + {hasCol('priority') ? ( + + + {issue.priority ?? '—'} + + + ) : null} + {hasCol('start_date') ? ( + + {startStr ?? '—'} + + ) : null} + {hasCol('due_date') ? ( + + + + ) : null} + {hasCol('assignee') ? ( + + {assignee ? ( + + ) : ( + + )} + + ) : null} + {hasCol('labels') ? ( + + {labelNames.length > 0 ? ( + + ) : ( + + + + )} + + ) : null} + {hasCol('sub_work_count') ? ( + + {subN} + + ) : null} + {hasCol('attachment_count') ? ( + + — + + ) : null} + {hasCol('estimate') ? ( + + ) : null} + {hasCol('module') ? ( + + {moduleName(issue)} + + ) : null} + {hasCol('cycle') ? ( + + {cycleName(issue)} + + ) : null} + {hasCol('link') ? ( + e.stopPropagation()} + > + + + ) : null} + + + + +
    + +
  • + ); + }; return (
    - {/*header + list share the canvas (no outer card). */}

    All work items {filteredIssues.length} @@ -423,90 +700,34 @@ export function IssueListPage() {

    ) : ( <> -
      - {filteredIssues.map((issue) => { - const primaryAssigneeId = - issue.assignee_ids && issue.assignee_ids.length > 0 ? issue.assignee_ids[0] : null; - const assignee = getUser(primaryAssigneeId); - const labelNames = getLabelNames(issue.label_ids ?? []); - const displayId = `${project.identifier ?? project.id.slice(0, 8)}-${issue.sequence_id ?? issue.id.slice(-4)}`; - return ( -
    • - - - {displayId} - {issue.name} - -
      - - - {getStateName(issue.state_id ?? undefined)} - - - - - {issue.priority ?? '—'} - - - - - - - {assignee ? ( - - ) : ( - - )} - - - {labelNames.length > 0 ? ( - - ) : ( - - - - )} - - - + {groupedIssues.isFlat ? ( +
        + {(groupedIssues.groups.get(groupedIssues.order[0]) ?? []).map((issue) => + renderIssueRow(issue), + )} +
      + ) : ( +
      + {groupedIssues.order.map((sectionKey) => { + const sectionIssues = groupedIssues.groups.get(sectionKey) ?? []; + if (sectionIssues.length === 0 && !listDisplay.showEmptyGroups) return null; + const title = groupedIssues.title(sectionKey); + return ( +
      +

      + {title} + + {sectionIssues.length} - -

      - -
    • - ); - })} -
    + +
      + {sectionIssues.map((issue) => renderIssueRow(issue))} +
    + + ); + })} +
    + )}