ui-nyla/src/pages/views/redmine/RedmineBrowserView.tsx
2026-04-21 23:56:19 +02:00

721 lines
28 KiB
TypeScript

/**
* 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<number, boolean>,
): 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<RedmineConfigDto | null>(null);
const [schema, setSchema] = useState<RedmineFieldSchema | null>(null);
const [tickets, setTickets] = useState<RedmineTicket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [period, setPeriod] = useState<PeriodValue | null>(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<Set<number> | null>(null);
const [selectedAssigneeIds, setSelectedAssigneeIds] = useState<Set<number>>(new Set());
const [selectedRelTypes, setSelectedRelTypes] = useState<Set<string>>(
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<Set<string>>(new Set());
// UI state
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [selectedId, setSelectedId] = useState<number | null>(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<number, boolean>();
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<string, string>();
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<number, RedmineTicket>();
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 <div className={styles.placeholder}>{t('Keine Feature-Instanz ausgewaehlt')}</div>;
}
return (
<div className={styles.browserPage}>
<div className={styles.browserHeader}>
<div>
<h2 className={styles.heading} style={{ margin: 0 }}>{t('Redmine -- Ticket-Browser')}</h2>
<p className={styles.subheading} style={{ margin: 0 }}>
{t('Baum aus dem lokalen Mirror. Roots: {name}. Tickets ohne Verbindung landen unter "Orphan User Story".', {
name: schema?.rootTrackerName || config?.rootTrackerName || '—',
})}
</p>
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #718096)' }}>
{t('{count} von {total} Tickets sichtbar', {
count: filteredTickets.length,
total: tickets.length,
})}
</div>
</div>
{error && <div className={styles.alertErr}>{error}</div>}
<div className={styles.browserFilters}>
<div className={styles.filterGroup}>
<label>{t('Zeitraum (letzte Aenderung)')}</label>
<PeriodPicker
value={period}
onChange={(next) => setPeriod(next)}
direction="past"
defaultPreset={{ kind: 'lastQuarter' }}
enabledPresets={[
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
]}
placeholder={t('Alle Zeiten')}
/>
</div>
<div className={styles.filterGroup}>
<label>Status</label>
<select
className={styles.select}
value={statusFilter}
onChange={e => setStatusFilter(e.target.value as any)}
>
<option value="*">{t('Alle')}</option>
<option value="open">{t('Nur offen')}</option>
<option value="closed">{t('Nur geschlossen')}</option>
</select>
</div>
{schema && schema.trackers.length > 0 && (
<div className={styles.filterGroup} style={{ minWidth: 240 }}>
<label>{t('Tracker (Klick blendet aus -- Root bleibt immer aktiv)')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{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 (
<button
key={tr.id}
type="button"
onClick={() => !isRoot && _toggleTracker(tr.id)}
style={{
padding: '2px 9px',
borderRadius: 999,
border: `1px solid ${active ? sty.border : 'var(--border-color, #e2e8f0)'}`,
background: active ? sty.bg : '#fff',
color: active ? sty.fg : 'var(--text-secondary, #4a5568)',
fontSize: '0.75rem',
cursor: isRoot ? 'default' : 'pointer',
fontFamily: 'inherit',
opacity: isRoot ? 0.9 : 1,
textDecoration: !active ? 'line-through' : 'none',
}}
title={
isRoot
? t('Root-Tracker -- immer aktiv')
: active
? t('Klicken, um {name} auszublenden', { name: tr.name })
: t('Klicken, um {name} wieder anzuzeigen', { name: tr.name })
}
>
{isRoot ? `${tr.name}` : tr.name}
</button>
);
})}
</div>
</div>
)}
{schema && schema.users.length > 0 && (
<div className={styles.filterGroup} style={{ minWidth: 220 }}>
<label>{t('Zuweisung')}</label>
<select
className={styles.select}
multiple
size={Math.min(4, schema.users.length)}
value={Array.from(selectedAssigneeIds).map(String)}
onChange={e => {
const ids = Array.from(e.target.selectedOptions).map(o => Number(o.value));
setSelectedAssigneeIds(new Set(ids));
}}
style={{ height: 'auto' }}
>
{schema.users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
)}
{sprintOptions.length > 0 && (
<div className={styles.filterGroup} style={{ minWidth: 220 }}>
<label>{t('Sprint (Zielversion)')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 88, overflow: 'auto' }}>
{sprintOptions.map(sp => {
const active = selectedSprints.has(sp.value);
return (
<button
key={sp.value}
type="button"
onClick={() => _toggleSprint(sp.value)}
style={{
padding: '2px 8px',
borderRadius: 4,
border: `1px solid ${active ? '#4A6FA5' : 'var(--border-color, #e2e8f0)'}`,
background: active ? '#4A6FA5' : '#fff',
color: active ? '#fff' : 'var(--text-secondary, #4a5568)',
fontSize: '0.72rem',
cursor: 'pointer',
fontFamily: 'inherit',
}}
title={sp.label}
>
{sp.label}
</button>
);
})}
</div>
{selectedSprints.size === 0 && (
<span style={{ fontSize: '0.7rem', color: 'var(--text-secondary, #718096)' }}>
{t('keine Auswahl = alle Sprints')}
</span>
)}
</div>
)}
<div className={styles.filterGroup} style={{ minWidth: 260 }}>
<label>{t('Beziehungsarten')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{RELATION_TYPE_OPTIONS.map(rt => {
const active = selectedRelTypes.has(rt.value);
return (
<button
key={rt.value}
type="button"
onClick={() => _toggleRelType(rt.value)}
title={t('Beziehungstyp ein-/ausschalten -- nur aktive Typen werden im Baum verfolgt')}
style={{
padding: '2px 8px',
borderRadius: 4,
border: `1px solid ${active ? '#4A6FA5' : 'var(--border-color, #e2e8f0)'}`,
background: active ? '#4A6FA5' : '#fff',
color: active ? '#fff' : 'var(--text-secondary, #4a5568)',
fontSize: '0.72rem',
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{rt.label}
</button>
);
})}
</div>
</div>
<div style={{ flex: 1 }} />
<button className={styles.btnSecondary} onClick={_resetFilters}>
{t('Filter zuruecksetzen')}
</button>
</div>
<div className={styles.browserBody}>
{/* Left: tree */}
<div className={styles.browserTreeContainer}>
<div className={styles.browserToolbar}>
<span>
{t('{rows} Zeilen sichtbar -- {orphans} Orphan-Tickets', {
rows: flatRows.length,
orphans: forest.orphanCount,
})}
</span>
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button className={styles.linkBtn} onClick={_expandAll} disabled={flatRows.length === 0}>
{t('Alle ausklappen')}
</button>
<button className={styles.linkBtn} onClick={_collapseAll} disabled={flatRows.length === 0}>
{t('Einklappen')}
</button>
</div>
</div>
<div className={styles.treeScroll}>
{loading ? (
<div className={styles.loading}>{t('Baum wird aufgebaut ...')}</div>
) : flatRows.length === 0 ? (
<div className={styles.placeholder}>
{t('Keine Tickets sichtbar. Pruefe Filter oder fuehre einen Sync auf der Einstellungen-Seite aus.')}
</div>
) : (
<div className={styles.treeGrid}>
<div className={styles.treeHeader}>
<div>{t('Ticket')}</div>
<div>Status</div>
<div>{t('Prio')}</div>
<div>{t('Zuweisung')}</div>
<div>{t('Geaendert')}</div>
<div>{t('Beziehung')}</div>
</div>
{flatRows.map(row => (
<TreeRow
key={row.node.id}
row={row}
ticket={ticketsById.get(row.node.id) || null}
selected={selectedId === row.node.id}
expanded={expanded.has(row.node.id)}
onToggle={_toggleExpand}
onSelect={setSelectedId}
rootTrackerId={rootTrackerId}
schemaStatusClosedById={schemaStatusClosedById}
/>
))}
</div>
)}
</div>
</div>
{/* Right: editor */}
<div className={styles.browserEditorContainer}>
{selectedId == null ? (
<div className={styles.browserToolbar}>{t('Ticket links auswaehlen')}</div>
) : selectedId === ORPHAN_ROOT_ID ? (
<div className={styles.browserToolbar}>
{t('Virtueller "Orphan User Story"-Knoten -- enthaelt {count} Tickets ohne Verbindung.', {
count: forest.orphanCount,
})}
</div>
) : (
<RedmineTicketEditor
instanceId={instanceId}
ticketId={selectedId}
schema={schema}
baseUrl={config?.baseUrl || ''}
onSaved={_handleTicketSaved}
/>
)}
</div>
</div>
</div>
);
};
// ============================================================================
// 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<number, boolean>;
}
const _TreeRowImpl: React.FC<TreeRowProps> = ({
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 (
<div className={rowClass} onClick={_handleSelect}>
<div className={styles.treeCell}>
{Array.from({ length: Math.max(0, depth - 1) }).map((_, i) => (
<span
key={i}
className={`${styles.indent}${indentLines[i] ? ` ${styles.line ?? 'line'}` : ''}`}
/>
))}
{depth > 0 && (
<span className={`${styles.indentElbow}${row.isLast ? '' : ` ${styles.mid ?? 'mid'}`}`}>
<span className={styles.elbowBar} />
</span>
)}
{hasChildren ? (
<button className={styles.treeToggle} onClick={_handleToggle} title={expanded ? 'Einklappen' : 'Ausklappen'}>
{expanded ? '▾' : '▸'}
</button>
) : (
<span className={styles.treeTogglePlaceholder} />
)}
<span
className={`${styles.trackerPill} ${isOrphanRoot ? styles.orphanPill : ''}`}
style={
!isOrphanRoot && ticket?.trackerName
? (() => {
const sty = getTrackerStyle(ticket.trackerName);
return {
background: sty.bg,
color: sty.fg,
border: `1px solid ${sty.border}`,
};
})()
: undefined
}
>
{isOrphanRoot ? 'Orphan' : (ticket?.trackerName || '—')}
</span>
<span className={styles.ticketId}>
{isOrphanRoot ? '—' : `#${ticket?.id}`}
</span>
<span className={styles.ticketSubject} title={isOrphanRoot ? '' : (ticket?.subject || '')}>
{isOrphanRoot
? `Tickets ohne Verbindung zu einer User Story (${node.children.length})`
: (ticket?.subject || '(ohne Titel)')}
</span>
</div>
<div>
{isOrphanRoot ? <span className={styles.muted}></span>
: ticket?.statusName
? <span className={`${styles.statusPill}${isClosed ? ` ${styles.closed ?? 'closed'}` : ''}`}>{ticket.statusName}</span>
: <span className={styles.muted}></span>}
</div>
<div>{isOrphanRoot ? <span className={styles.muted}></span> : (ticket?.priorityName || '—')}</div>
<div>{isOrphanRoot ? <span className={styles.muted}></span> : (ticket?.assignedToName || <span className={styles.muted}></span>)}</div>
<div style={{ fontVariantNumeric: 'tabular-nums' }}>
{isOrphanRoot ? '' : (ticket?.updatedOn ? ticket.updatedOn.slice(0, 10) : '')}
</div>
<div>
{isOrphanRoot
? <span className={styles.relBadge}>virtuell</span>
: isRootTracker && depth === 0
? <span className={`${styles.relBadge} ${styles.root ?? 'root'}`}>Root</span>
: node.relType
? <span className={styles.relBadge}>{node.dir === 'in' ? '←' : '→'} {node.relType}</span>
: <span className={styles.muted}></span>}
</div>
</div>
);
};
// 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;