/** * Redmine Ticket Browser * * Split view: tree-as-table on the left (roots = configured root * tracker + virtual "Orphan" root), editor pane on the right. All reads * hit the local mirror; saves go through ``updateRedmineTicketApi`` * which updates Redmine and then refreshes the mirror. * * Filters are applied client-side because the mirror already fits in * memory (2-20k tickets is fine for a sub-200ms filter pass). */ import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, } from 'react'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { RedmineConfigDto, RedmineFieldSchema, RedmineTicket, getRedmineConfigApi, getRedmineSchemaApi, listRedmineTicketsApi, } from '../../../api/redmineApi'; import { PeriodPicker, PeriodValue } from '../../../components/PeriodPicker'; import { Forest, FlatRow, ORPHAN_ROOT_ID, buildForest, collectAllIds, flattenForest, } from './redmineTreeLogic'; import { getTrackerStyle, sortByTrackerOrder } from './redmineTrackerColor'; import RedmineTicketEditor from './RedmineTicketEditor'; import styles from './RedmineViews.module.css'; // ============================================================================ // Relation type options -- Redmine's fixed vocabulary plus our synthetic // "parent" edge (inherited from ``parent_id``). // ============================================================================ const RELATION_TYPE_OPTIONS: Array<{ value: string; label: string }> = [ { value: 'parent', label: 'parent_id' }, { value: 'relates', label: 'relates' }, { value: 'duplicates', label: 'duplicates' }, { value: 'duplicated', label: 'duplicated' }, { value: 'blocks', label: 'blocks' }, { value: 'blocked', label: 'blocked' }, { value: 'precedes', label: 'precedes' }, { value: 'follows', label: 'follows' }, { value: 'copied_to', label: 'copied_to' }, { value: 'copied_from', label: 'copied_from' }, ]; // ============================================================================ // Closed-state lookup -- the mirror's ``isClosed`` field can be stale or // missing when the schema cache wasn't yet hydrated at sync time. We trust // the live schema (``schema.statuses[*].isClosed``) as the source of truth // and fall back to the ticket's own flag. // ============================================================================ const _isTicketClosed = ( ticket: RedmineTicket, schemaStatusClosedById: Map, ): boolean => { if (ticket.statusId != null && schemaStatusClosedById.has(ticket.statusId)) { return schemaStatusClosedById.get(ticket.statusId) === true; } return !!ticket.isClosed; }; export const RedmineBrowserView: React.FC = () => { const { t } = useLanguage(); const { request } = useApiRequest(); const instanceId = useInstanceId(); const [config, setConfig] = useState(null); const [schema, setSchema] = useState(null); const [tickets, setTickets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Filters const [period, setPeriod] = useState(null); // Tracker filter is *subtractive*: the set holds tracker ids that are // currently SHOWN. ``null`` means "uninitialised" -- once the schema is // loaded we seed the set with all tracker ids so every chip starts active. // Clicking a chip removes it from the set -> tickets of that tracker // disappear from the list. const [selectedTrackerIds, setSelectedTrackerIds] = useState | null>(null); const [selectedAssigneeIds, setSelectedAssigneeIds] = useState>(new Set()); const [selectedRelTypes, setSelectedRelTypes] = useState>( new Set(RELATION_TYPE_OPTIONS.map(r => r.value)), ); const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*'); // Sprint = Redmine "fixed_version". Empty set => no filter; the synthetic // value ``__none__`` matches tickets with no sprint assigned. const [selectedSprints, setSelectedSprints] = useState>(new Set()); // UI state const [expanded, setExpanded] = useState>(new Set()); const [selectedId, setSelectedId] = useState(null); const rootTrackerId = schema?.rootTrackerId ?? null; // Seed the tracker filter once the schema is available: every tracker // starts SELECTED so the user sees everything by default; clicking a chip // removes that tracker from the visible set. useEffect(() => { if (selectedTrackerIds == null && schema) { setSelectedTrackerIds(new Set(schema.trackers.map(tr => tr.id))); } }, [schema, selectedTrackerIds]); // Map statusId -> isClosed, taken from the live schema. Used by the status // filter so it works even if the mirror's per-ticket ``isClosed`` is stale. const schemaStatusClosedById = useMemo(() => { const m = new Map(); if (schema) { for (const s of schema.statuses) { if (typeof s.isClosed === 'boolean') m.set(s.id, s.isClosed); } } return m; }, [schema]); // Distinct sprints (fixed_version) seen across all loaded tickets. Drives // the sprint filter dropdown. Sorted alphabetically for stable UI. const sprintOptions = useMemo(() => { const m = new Map(); let hasNone = false; for (const tk of tickets) { const name = tk.fixedVersionName?.trim(); if (name) { m.set(name, name); } else { hasNone = true; } } const opts = Array.from(m.entries()) .map(([value, label]) => ({ value, label })) .sort((a, b) => a.label.localeCompare(b.label)); if (hasNone) opts.unshift({ value: '__none__', label: t('(ohne Sprint)') }); return opts; }, [tickets, t]); // Load config + schema once. const _loadMeta = useCallback(async () => { if (!instanceId) return; try { const [c, s] = await Promise.all([ getRedmineConfigApi(request, instanceId), getRedmineSchemaApi(request, instanceId), ]); setConfig(c); setSchema(s); } catch (e: any) { setError(e?.response?.data?.detail || e?.message || t('Konfiguration laden fehlgeschlagen')); } }, [request, instanceId, t]); // Load tickets from mirror whenever the period window changes (backend can // pre-filter by updatedOn to shrink the payload). const _loadTickets = useCallback(async () => { if (!instanceId) return; setLoading(true); setError(null); try { const result = await listRedmineTicketsApi(request, instanceId, { status: '*', dateFrom: period?.fromDate, dateTo: period?.toDate, }); setTickets(result); } catch (e: any) { setError(e?.response?.data?.detail || e?.message || t('Tickets laden fehlgeschlagen')); setTickets([]); } finally { setLoading(false); } }, [request, instanceId, period, t]); useEffect(() => { _loadMeta(); }, [_loadMeta]); useEffect(() => { _loadTickets(); }, [_loadTickets]); // Client-side filter pass (tracker / assignee / status). Root-tracker // tickets are always kept so the tree has roots even if their own tracker // is deselected -- otherwise the whole forest collapses. // ``deferredFilters`` lets React keep the filter chips snappy while the // potentially expensive tree rebuild happens in the background. The // chips update immediately (urgent state) but the tree picks up the new // values one tick later, which removes the "click feels frozen" lag. const deferredSelectedTrackerIds = useDeferredValue(selectedTrackerIds); const deferredSelectedAssigneeIds = useDeferredValue(selectedAssigneeIds); const deferredSelectedRelTypes = useDeferredValue(selectedRelTypes); const deferredStatusFilter = useDeferredValue(statusFilter); const deferredSelectedSprints = useDeferredValue(selectedSprints); const filteredTickets = useMemo(() => { const trackerSet = deferredSelectedTrackerIds; const assigneeSet = deferredSelectedAssigneeIds; const sprintSet = deferredSelectedSprints; const status = deferredStatusFilter; return tickets.filter(ticket => { const isRoot = rootTrackerId != null && ticket.trackerId === rootTrackerId; if (!isRoot && trackerSet != null && ticket.trackerId != null) { if (!trackerSet.has(ticket.trackerId)) return false; } if (assigneeSet.size > 0) { if (ticket.assignedToId == null || !assigneeSet.has(ticket.assignedToId)) return false; } if (status !== '*') { const closed = _isTicketClosed(ticket, schemaStatusClosedById); if (status === 'open' && closed) return false; if (status === 'closed' && !closed) return false; } if (sprintSet.size > 0) { const sprintKey = ticket.fixedVersionName?.trim() || '__none__'; if (!sprintSet.has(sprintKey)) return false; } return true; }); }, [tickets, rootTrackerId, deferredSelectedTrackerIds, deferredSelectedAssigneeIds, deferredStatusFilter, deferredSelectedSprints, schemaStatusClosedById]); // Convert the rel-type set to an array once per change instead of on every // ``buildForest`` call (Array.from() in the deps would re-run the memo // every render because the array identity is fresh each time). const allowedRelTypesArr = useMemo( () => Array.from(deferredSelectedRelTypes), [deferredSelectedRelTypes], ); const forest: Forest = useMemo(() => { return buildForest(filteredTickets, { rootTrackerId, allowedRelTypes: allowedRelTypesArr, }); }, [filteredTickets, rootTrackerId, allowedRelTypesArr]); const flatRows: FlatRow[] = useMemo( () => flattenForest(forest.trees, expanded), [forest.trees, expanded], ); const ticketsById = useMemo(() => { const m = new Map(); for (const ticket of tickets) m.set(ticket.id, ticket); return m; }, [tickets]); // One-shot initial expansion: the very first time we render a non-empty // forest, expand the root nodes so the user sees the overview. After that // the user owns the expand state -- collapsing all must STAY collapsed. const _didInitExpand = useRef(false); useEffect(() => { if (!_didInitExpand.current && forest.trees.length > 0) { _didInitExpand.current = true; setExpanded(new Set(forest.trees.map(tr => tr.id))); } }, [forest.trees]); const _toggleExpand = useCallback((id: number) => { setExpanded(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const _expandAll = useCallback(() => { setExpanded(new Set(collectAllIds(forest.trees))); }, [forest.trees]); const _collapseAll = useCallback(() => { setExpanded(new Set()); }, []); const _resetFilters = useCallback(() => { setPeriod(null); // "Alle Tracker sichtbar" entspricht dem initialen, voll bestueckten Set. setSelectedTrackerIds(schema ? new Set(schema.trackers.map(tr => tr.id)) : null); setSelectedAssigneeIds(new Set()); setSelectedRelTypes(new Set(RELATION_TYPE_OPTIONS.map(r => r.value))); setStatusFilter('*'); setSelectedSprints(new Set()); }, [schema]); const _toggleSprint = useCallback((value: string) => { setSelectedSprints(prev => { const next = new Set(prev); if (next.has(value)) next.delete(value); else next.add(value); return next; }); }, []); const _toggleTracker = useCallback((id: number) => { setSelectedTrackerIds(prev => { // ``prev`` is null only before schema loads -- the chip wouldn't be // clickable in that state, but stay defensive. const next = new Set(prev ?? []); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const _toggleRelType = useCallback((rt: string) => { setSelectedRelTypes(prev => { const next = new Set(prev); if (next.has(rt)) next.delete(rt); else next.add(rt); return next; }); }, []); const _handleTicketSaved = useCallback((updated: RedmineTicket) => { setTickets(prev => prev.map(x => (x.id === updated.id ? updated : x))); }, []); if (!instanceId) { return
{t('Keine Feature-Instanz ausgewaehlt')}
; } return (

{t('Redmine -- Ticket-Browser')}

{t('Baum aus dem lokalen Mirror. Roots: {name}. Tickets ohne Verbindung landen unter "Orphan User Story".', { name: schema?.rootTrackerName || config?.rootTrackerName || '—', })}

{t('{count} von {total} Tickets sichtbar', { count: filteredTickets.length, total: tickets.length, })}
{error &&
{error}
}
setPeriod(next)} direction="past" defaultPreset={{ kind: 'lastQuarter' }} enabledPresets={[ 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom', ]} placeholder={t('Alle Zeiten')} />
{schema && schema.trackers.length > 0 && (
{sortByTrackerOrder(schema.trackers, tr => tr.name).map(tr => { const isRoot = tr.id === rootTrackerId; // Active iff in the visible set (or root). selectedTrackerIds // is null only during the brief window before the schema seed // effect runs -- treat as "all visible". const active = isRoot || selectedTrackerIds == null || selectedTrackerIds.has(tr.id); const sty = getTrackerStyle(tr.name); return ( ); })}
)} {schema && schema.users.length > 0 && (
)} {sprintOptions.length > 0 && (
{sprintOptions.map(sp => { const active = selectedSprints.has(sp.value); return ( ); })}
{selectedSprints.size === 0 && ( {t('keine Auswahl = alle Sprints')} )}
)}
{RELATION_TYPE_OPTIONS.map(rt => { const active = selectedRelTypes.has(rt.value); return ( ); })}
{/* Left: tree */}
{t('{rows} Zeilen sichtbar -- {orphans} Orphan-Tickets', { rows: flatRows.length, orphans: forest.orphanCount, })}
{loading ? (
{t('Baum wird aufgebaut ...')}
) : flatRows.length === 0 ? (
{t('Keine Tickets sichtbar. Pruefe Filter oder fuehre einen Sync auf der Einstellungen-Seite aus.')}
) : (
{t('Ticket')}
Status
{t('Prio')}
{t('Zuweisung')}
{t('Geaendert')}
{t('Beziehung')}
{flatRows.map(row => ( ))}
)}
{/* Right: editor */}
{selectedId == null ? (
{t('Ticket links auswaehlen')}
) : selectedId === ORPHAN_ROOT_ID ? (
{t('Virtueller "Orphan User Story"-Knoten -- enthaelt {count} Tickets ohne Verbindung.', { count: forest.orphanCount, })}
) : ( )}
); }; // ============================================================================ // TreeRow -- a single grid row, handling its own indent painting. // ============================================================================ interface TreeRowProps { row: FlatRow; ticket: RedmineTicket | null; selected: boolean; expanded: boolean; onToggle: (id: number) => void; onSelect: (id: number) => void; rootTrackerId: number | null; schemaStatusClosedById: Map; } const _TreeRowImpl: React.FC = ({ row, ticket, selected, expanded, onToggle, onSelect, rootTrackerId, schemaStatusClosedById, }) => { const { node, depth, indentLines, hasChildren } = row; const isOrphanRoot = node.id === ORPHAN_ROOT_ID; const isRootTracker = !isOrphanRoot && ticket?.trackerId === rootTrackerId; const isClosed = ticket ? _isTicketClosed(ticket, schemaStatusClosedById) : false; const rowClass = [ styles.treeRow, selected ? styles.selected : '', isOrphanRoot ? styles.orphan : '', ].filter(Boolean).join(' '); const _handleToggle = (e: React.MouseEvent) => { e.stopPropagation(); onToggle(node.id); }; const _handleSelect = () => onSelect(node.id); return (
{Array.from({ length: Math.max(0, depth - 1) }).map((_, i) => ( ))} {depth > 0 && ( )} {hasChildren ? ( ) : ( )} { const sty = getTrackerStyle(ticket.trackerName); return { background: sty.bg, color: sty.fg, border: `1px solid ${sty.border}`, }; })() : undefined } > {isOrphanRoot ? 'Orphan' : (ticket?.trackerName || '—')} {isOrphanRoot ? '—' : `#${ticket?.id}`} {isOrphanRoot ? `Tickets ohne Verbindung zu einer User Story (${node.children.length})` : (ticket?.subject || '(ohne Titel)')}
{isOrphanRoot ? : ticket?.statusName ? {ticket.statusName} : }
{isOrphanRoot ? : (ticket?.priorityName || '—')}
{isOrphanRoot ? : (ticket?.assignedToName || )}
{isOrphanRoot ? '' : (ticket?.updatedOn ? ticket.updatedOn.slice(0, 10) : '')}
{isOrphanRoot ? virtuell : isRootTracker && depth === 0 ? Root : node.relType ? {node.dir === 'in' ? '←' : '→'} {node.relType} : }
); }; // Custom equality: skip re-rendering rows whose visible inputs are identical. // ``row`` and ``ticket`` references are stable across re-renders unless the // underlying tree was rebuilt or the ticket itself changed -- much cheaper // than re-painting 2k DOM nodes on every filter chip click. const TreeRow = React.memo(_TreeRowImpl, (prev, next) => { return ( prev.row === next.row && prev.ticket === next.ticket && prev.selected === next.selected && prev.expanded === next.expanded && prev.onToggle === next.onToggle && prev.onSelect === next.onSelect && prev.rootTrackerId === next.rootTrackerId && prev.schemaStatusClosedById === next.schemaStatusClosedById ); }); export default RedmineBrowserView;