hale-commenting-system 3.5.2 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -8
- package/package.json +1 -1
- package/src/app/commenting-system/components/CommentOverlay.tsx +282 -14
- package/src/app/commenting-system/components/CommentPanel.tsx +67 -12
- package/src/app/commenting-system/components/CommentPin.tsx +87 -5
- package/src/app/commenting-system/components/FloatingWidget.tsx +79 -9
- package/src/app/commenting-system/components/JiraTab.tsx +295 -166
- package/src/app/commenting-system/contexts/CommentContext.tsx +77 -33
- package/src/app/commenting-system/index.ts +4 -1
- package/src/app/commenting-system/services/githubAdapter.ts +9 -2
- package/src/app/commenting-system/types/index.ts +14 -2
- package/src/app/commenting-system/utils/componentUtils.ts +242 -0
- package/src/app/commenting-system/utils/selectorUtils.ts +155 -0
|
@@ -30,7 +30,16 @@ type JiraTicket = {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
interface JiraRecord {
|
|
33
|
-
|
|
33
|
+
jiraKeys: string[];
|
|
34
|
+
scope: JiraScope;
|
|
35
|
+
anchorRoute: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Legacy format for backward compatibility
|
|
40
|
+
interface LegacyJiraRecord {
|
|
41
|
+
jiraKey?: string;
|
|
42
|
+
jiraKeys?: string[];
|
|
34
43
|
scope: JiraScope;
|
|
35
44
|
anchorRoute: string;
|
|
36
45
|
updatedAt: string;
|
|
@@ -41,12 +50,41 @@ type JiraStore = Record<string, JiraRecord>;
|
|
|
41
50
|
const STORAGE_KEY = 'hale_commenting_jira_v1';
|
|
42
51
|
const GH_JIRA_PATH = '.hale/jira.json';
|
|
43
52
|
|
|
53
|
+
function migrateRecord(record: LegacyJiraRecord): JiraRecord {
|
|
54
|
+
// If it's already in the new format, return as-is
|
|
55
|
+
if (Array.isArray(record.jiraKeys)) {
|
|
56
|
+
return record as JiraRecord;
|
|
57
|
+
}
|
|
58
|
+
// Migrate from old format (single jiraKey)
|
|
59
|
+
if (typeof record.jiraKey === 'string' && record.jiraKey.trim()) {
|
|
60
|
+
return {
|
|
61
|
+
jiraKeys: [record.jiraKey.trim()],
|
|
62
|
+
scope: record.scope,
|
|
63
|
+
anchorRoute: record.anchorRoute,
|
|
64
|
+
updatedAt: record.updatedAt,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Empty record - return with empty array
|
|
68
|
+
return {
|
|
69
|
+
jiraKeys: [],
|
|
70
|
+
scope: record.scope,
|
|
71
|
+
anchorRoute: record.anchorRoute,
|
|
72
|
+
updatedAt: record.updatedAt,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
function safeParseStore(raw: string | null): JiraStore {
|
|
45
77
|
if (!raw) return {};
|
|
46
78
|
try {
|
|
47
79
|
const parsed = JSON.parse(raw) as unknown;
|
|
48
80
|
if (!parsed || typeof parsed !== 'object') return {};
|
|
49
|
-
|
|
81
|
+
const store = parsed as Record<string, LegacyJiraRecord>;
|
|
82
|
+
// Migrate all records to new format
|
|
83
|
+
const migrated: JiraStore = {};
|
|
84
|
+
for (const [key, record] of Object.entries(store)) {
|
|
85
|
+
migrated[key] = migrateRecord(record);
|
|
86
|
+
}
|
|
87
|
+
return migrated;
|
|
50
88
|
} catch {
|
|
51
89
|
return {};
|
|
52
90
|
}
|
|
@@ -86,22 +124,37 @@ function getSectionKey(sectionRoute: string): string {
|
|
|
86
124
|
function loadForRoute(pathname: string): { record: JiraRecord | null; source: 'page' | 'section' | null } {
|
|
87
125
|
const store = getStore();
|
|
88
126
|
const pageKey = getPageKey(pathname);
|
|
89
|
-
if (store[pageKey]
|
|
127
|
+
if (store[pageKey] && store[pageKey].jiraKeys.length > 0) {
|
|
128
|
+
return { record: store[pageKey], source: 'page' };
|
|
129
|
+
}
|
|
90
130
|
|
|
91
131
|
const sectionRoute = getSectionRoute(pathname);
|
|
92
132
|
const sectionKey = getSectionKey(sectionRoute);
|
|
93
|
-
if (store[sectionKey]
|
|
133
|
+
if (store[sectionKey] && store[sectionKey].jiraKeys.length > 0) {
|
|
134
|
+
return { record: store[sectionKey], source: 'section' };
|
|
135
|
+
}
|
|
94
136
|
|
|
95
137
|
return { record: null, source: null };
|
|
96
138
|
}
|
|
97
139
|
|
|
98
|
-
const
|
|
140
|
+
const normalizeJiraKeys = (input: string): string[] => {
|
|
99
141
|
const raw = input.trim();
|
|
100
|
-
if (!raw) return
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
142
|
+
if (!raw) return [];
|
|
143
|
+
|
|
144
|
+
// Split by comma, newline, or whitespace, then normalize each
|
|
145
|
+
const keys = raw
|
|
146
|
+
.split(/[,\n]+/)
|
|
147
|
+
.map(k => k.trim())
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.map(key => {
|
|
150
|
+
// Allow users to paste full URLs; extract trailing key-ish segment.
|
|
151
|
+
const m = key.match(/([A-Z][A-Z0-9]+-\d+)/i);
|
|
152
|
+
if (m?.[1]) return m[1].toUpperCase();
|
|
153
|
+
return key.toUpperCase();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Remove duplicates and empty strings
|
|
157
|
+
return Array.from(new Set(keys.filter(Boolean)));
|
|
105
158
|
};
|
|
106
159
|
|
|
107
160
|
const stripHtmlTags = (input: string): string => {
|
|
@@ -237,9 +290,9 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
237
290
|
const [remoteError, setRemoteError] = React.useState<string | null>(null);
|
|
238
291
|
const remoteShaRef = React.useRef<string | undefined>(undefined);
|
|
239
292
|
|
|
240
|
-
const [
|
|
241
|
-
const [
|
|
242
|
-
const [
|
|
293
|
+
const [isFetchingIssues, setIsFetchingIssues] = React.useState(false);
|
|
294
|
+
const [issues, setIssues] = React.useState<Record<string, JiraTicket>>({});
|
|
295
|
+
const [issueErrors, setIssueErrors] = React.useState<Record<string, string>>({});
|
|
243
296
|
|
|
244
297
|
React.useEffect(() => {
|
|
245
298
|
setResolved(loadForRoute(route));
|
|
@@ -286,40 +339,47 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
286
339
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
287
340
|
}, []);
|
|
288
341
|
|
|
289
|
-
// Fetch Jira issue details for
|
|
342
|
+
// Fetch Jira issue details for all keys.
|
|
290
343
|
React.useEffect(() => {
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
344
|
+
const keys = record?.jiraKeys || [];
|
|
345
|
+
if (keys.length === 0) {
|
|
346
|
+
setIssues({});
|
|
347
|
+
setIssueErrors({});
|
|
295
348
|
return;
|
|
296
349
|
}
|
|
297
350
|
|
|
298
351
|
const run = async () => {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
352
|
+
setIsFetchingIssues(true);
|
|
353
|
+
setIssueErrors({});
|
|
354
|
+
const results: Record<string, JiraTicket> = {};
|
|
355
|
+
const errors: Record<string, string> = {};
|
|
356
|
+
|
|
357
|
+
await Promise.all(
|
|
358
|
+
keys.map(async (key) => {
|
|
359
|
+
try {
|
|
360
|
+
const resp = await fetch(`/api/jira-issue?key=${encodeURIComponent(key)}`);
|
|
361
|
+
const payload = await resp.json().catch(() => ({}));
|
|
362
|
+
if (!resp.ok) {
|
|
363
|
+
const raw = String(payload?.message || `Failed to fetch Jira issue (${resp.status})`);
|
|
364
|
+
const sanitized = raw.trim().startsWith('<') ? 'Unauthorized or non-JSON response from Jira.' : raw;
|
|
365
|
+
const hint = payload?.hint ? ` ${String(payload.hint)}` : '';
|
|
366
|
+
errors[key] = `${sanitized}${hint}`;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
results[key] = payload as JiraTicket;
|
|
370
|
+
} catch (e: any) {
|
|
371
|
+
errors[key] = e?.message || 'Failed to fetch Jira issue';
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
setIssues(results);
|
|
377
|
+
setIssueErrors(errors);
|
|
378
|
+
setIsFetchingIssues(false);
|
|
319
379
|
};
|
|
320
380
|
|
|
321
381
|
void run();
|
|
322
|
-
}, [record?.
|
|
382
|
+
}, [record?.jiraKeys]);
|
|
323
383
|
|
|
324
384
|
const startNew = () => {
|
|
325
385
|
setDraftScope('section');
|
|
@@ -330,20 +390,23 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
330
390
|
const startEdit = (mode: 'edit-existing' | 'override-page') => {
|
|
331
391
|
if (mode === 'override-page') {
|
|
332
392
|
setDraftScope('page');
|
|
333
|
-
setDraftKey(record?.
|
|
393
|
+
setDraftKey(record?.jiraKeys.join(', ') ?? '');
|
|
334
394
|
setIsEditing(true);
|
|
335
395
|
return;
|
|
336
396
|
}
|
|
337
397
|
|
|
338
398
|
const existingScope: JiraScope = record?.scope ?? 'section';
|
|
339
399
|
setDraftScope(existingScope);
|
|
340
|
-
setDraftKey(record?.
|
|
400
|
+
setDraftKey(record?.jiraKeys.join(', ') ?? '');
|
|
341
401
|
setIsEditing(true);
|
|
342
402
|
};
|
|
343
403
|
|
|
344
404
|
const save = () => {
|
|
405
|
+
const normalizedKeys = normalizeJiraKeys(draftKey);
|
|
406
|
+
if (normalizedKeys.length === 0) return;
|
|
407
|
+
|
|
345
408
|
const next: JiraRecord = {
|
|
346
|
-
|
|
409
|
+
jiraKeys: normalizedKeys,
|
|
347
410
|
scope: draftScope,
|
|
348
411
|
anchorRoute: draftScope === 'section' ? sectionRoute : route,
|
|
349
412
|
updatedAt: new Date().toISOString(),
|
|
@@ -450,8 +513,8 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
450
513
|
</Button>
|
|
451
514
|
</div>
|
|
452
515
|
|
|
453
|
-
<EmptyState icon={InfoCircleIcon} titleText="No Jira
|
|
454
|
-
<EmptyStateBody>Add
|
|
516
|
+
<EmptyState icon={InfoCircleIcon} titleText="No Jira issues linked" headingLevel="h3">
|
|
517
|
+
<EmptyStateBody>Add Jira keys like <b>ABC-123</b> (or paste Jira URLs). You can add multiple issues separated by commas or new lines.</EmptyStateBody>
|
|
455
518
|
</EmptyState>
|
|
456
519
|
</div>
|
|
457
520
|
);
|
|
@@ -492,23 +555,26 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
492
555
|
<Card>
|
|
493
556
|
<CardBody>
|
|
494
557
|
<Title headingLevel="h4" size="md" style={{ marginBottom: '1rem' }}>
|
|
495
|
-
Jira
|
|
558
|
+
Jira issues
|
|
496
559
|
</Title>
|
|
497
560
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
|
498
561
|
<div>
|
|
499
562
|
<div style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
|
|
500
|
-
<b>Jira
|
|
563
|
+
<b>Jira keys or URLs</b>
|
|
564
|
+
<div style={{ fontSize: '0.75rem', color: 'var(--pf-t--global--text--color--subtle)', marginTop: '0.25rem' }}>
|
|
565
|
+
Enter multiple keys separated by commas or new lines (e.g., ABC-123, DEF-456)
|
|
566
|
+
</div>
|
|
501
567
|
</div>
|
|
502
568
|
<TextArea
|
|
503
569
|
value={draftKey}
|
|
504
570
|
onChange={(_e, v) => setDraftKey(v)}
|
|
505
|
-
aria-label="Jira
|
|
506
|
-
rows={
|
|
571
|
+
aria-label="Jira keys or URLs"
|
|
572
|
+
rows={3}
|
|
507
573
|
/>
|
|
508
574
|
</div>
|
|
509
575
|
|
|
510
576
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-start', marginTop: '0.5rem' }}>
|
|
511
|
-
<Button variant="primary" onClick={save} isDisabled={
|
|
577
|
+
<Button variant="primary" onClick={save} isDisabled={normalizeJiraKeys(draftKey).length === 0}>
|
|
512
578
|
Save
|
|
513
579
|
</Button>
|
|
514
580
|
<Button variant="link" onClick={() => setIsEditing(false)}>
|
|
@@ -529,25 +595,55 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
529
595
|
|
|
530
596
|
const scopeLabel =
|
|
531
597
|
source === 'page' ? 'This page' : source === 'section' ? `Section (${sectionRoute}/*)` : null;
|
|
532
|
-
const
|
|
598
|
+
const keys = record.jiraKeys || [];
|
|
533
599
|
let jiraBaseUrl: string | undefined;
|
|
534
600
|
try {
|
|
535
601
|
jiraBaseUrl = typeof process !== 'undefined' && process.env ? process.env.VITE_JIRA_BASE_URL : undefined;
|
|
536
602
|
} catch (e) {
|
|
537
603
|
jiraBaseUrl = undefined;
|
|
538
604
|
}
|
|
539
|
-
const url = issue?.url || (jiraBaseUrl ? `${jiraBaseUrl}/browse/${key}` : '');
|
|
540
605
|
|
|
541
|
-
const
|
|
542
|
-
|
|
606
|
+
const removeKey = (keyToRemove: string) => {
|
|
607
|
+
const store = getStore();
|
|
608
|
+
const storeKey = source === 'page' ? getPageKey(route) : source === 'section' ? getSectionKey(sectionRoute) : null;
|
|
609
|
+
if (!storeKey || !record) return;
|
|
610
|
+
|
|
611
|
+
const updatedKeys = record.jiraKeys.filter(k => k !== keyToRemove);
|
|
612
|
+
if (updatedKeys.length === 0) {
|
|
613
|
+
// If no keys left, remove the entire record
|
|
614
|
+
const { [storeKey]: _removed, ...rest } = store;
|
|
615
|
+
setStore(rest);
|
|
616
|
+
setResolved(loadForRoute(route));
|
|
617
|
+
} else {
|
|
618
|
+
// Update with remaining keys
|
|
619
|
+
const updated: JiraRecord = {
|
|
620
|
+
...record,
|
|
621
|
+
jiraKeys: updatedKeys,
|
|
622
|
+
updatedAt: new Date().toISOString(),
|
|
623
|
+
};
|
|
624
|
+
const nextStore = { ...store, [storeKey]: updated };
|
|
625
|
+
setStore(nextStore);
|
|
626
|
+
setResolved(loadForRoute(route));
|
|
627
|
+
}
|
|
543
628
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
629
|
+
if (isGitHubConfigured()) {
|
|
630
|
+
(async () => {
|
|
631
|
+
const finalStore = updatedKeys.length === 0
|
|
632
|
+
? Object.fromEntries(Object.entries(store).filter(([k]) => k !== storeKey))
|
|
633
|
+
: { ...store, [storeKey]: { ...record, jiraKeys: updatedKeys, updatedAt: new Date().toISOString() } };
|
|
634
|
+
const text = JSON.stringify(finalStore, null, 2) + '\n';
|
|
635
|
+
const message = `chore(jira): remove ${keyToRemove} from ${storeKey}`;
|
|
636
|
+
const sha = remoteShaRef.current;
|
|
637
|
+
const write = await githubAdapter.putRepoFile({ path: GH_JIRA_PATH, text, message, sha });
|
|
638
|
+
if (write.success && write.data?.sha) {
|
|
639
|
+
remoteShaRef.current = write.data.sha;
|
|
640
|
+
setRemoteError(null);
|
|
641
|
+
} else {
|
|
642
|
+
setRemoteError(write.error || 'Failed to update Jira store in GitHub');
|
|
643
|
+
}
|
|
644
|
+
})();
|
|
645
|
+
}
|
|
646
|
+
};
|
|
551
647
|
|
|
552
648
|
return (
|
|
553
649
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
@@ -582,119 +678,152 @@ export const JiraTab: React.FunctionComponent = () => {
|
|
|
582
678
|
</div>
|
|
583
679
|
</div>
|
|
584
680
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
<Label color="blue" isCompact>
|
|
592
|
-
{issueType || 'Issue'}
|
|
593
|
-
</Label>
|
|
594
|
-
{url ? (
|
|
595
|
-
<a
|
|
596
|
-
href={url}
|
|
597
|
-
target="_blank"
|
|
598
|
-
rel="noopener noreferrer"
|
|
599
|
-
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 600 }}
|
|
600
|
-
>
|
|
601
|
-
{key} <ExternalLinkAltIcon style={{ fontSize: '0.75rem' }} />
|
|
602
|
-
</a>
|
|
603
|
-
) : (
|
|
604
|
-
<span style={{ fontWeight: 600 }}>{key}</span>
|
|
605
|
-
)}
|
|
606
|
-
</div>
|
|
607
|
-
</div>
|
|
681
|
+
{/* Loading state */}
|
|
682
|
+
{isFetchingIssues && keys.length > 0 && (
|
|
683
|
+
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
684
|
+
<Spinner size="sm" /> <span>Fetching Jira details…</span>
|
|
685
|
+
</div>
|
|
686
|
+
)}
|
|
608
687
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
688
|
+
{/* Display all issues */}
|
|
689
|
+
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
690
|
+
{keys.map((key) => {
|
|
691
|
+
const issue = issues[key];
|
|
692
|
+
const error = issueErrors[key];
|
|
693
|
+
const url = issue?.url || (jiraBaseUrl ? `${jiraBaseUrl}/browse/${key}` : '');
|
|
694
|
+
|
|
695
|
+
const parsedSections = issue ? parseJiraTemplateSections(issue.description || '') : [];
|
|
696
|
+
const byTitle = new Map(parsedSections.map((s) => [s.title, s.body]));
|
|
697
|
+
|
|
698
|
+
const summary = issue?.summary || '';
|
|
699
|
+
const status = issue?.status || '';
|
|
700
|
+
const priority = issue?.priority || '';
|
|
701
|
+
const assignee = issue?.assignee || '';
|
|
702
|
+
const issueType = issue?.issueType || 'Issue';
|
|
703
|
+
const created = issue?.created ? new Date(issue.created).toLocaleString() : '';
|
|
704
|
+
const updated = issue?.updated ? new Date(issue.updated).toLocaleString() : '';
|
|
705
|
+
|
|
706
|
+
return (
|
|
707
|
+
<Card key={key}>
|
|
708
|
+
<CardBody>
|
|
709
|
+
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
710
|
+
{/* Ticket header */}
|
|
711
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
|
712
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
713
|
+
<Label color="blue" isCompact>
|
|
714
|
+
{issueType || 'Issue'}
|
|
715
|
+
</Label>
|
|
716
|
+
{url ? (
|
|
717
|
+
<a
|
|
718
|
+
href={url}
|
|
719
|
+
target="_blank"
|
|
720
|
+
rel="noopener noreferrer"
|
|
721
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 600 }}
|
|
722
|
+
>
|
|
723
|
+
{key} <ExternalLinkAltIcon style={{ fontSize: '0.75rem' }} />
|
|
724
|
+
</a>
|
|
725
|
+
) : (
|
|
726
|
+
<span style={{ fontWeight: 600 }}>{key}</span>
|
|
727
|
+
)}
|
|
728
|
+
</div>
|
|
729
|
+
{keys.length > 1 && (
|
|
730
|
+
<Button variant="link" isDanger onClick={() => removeKey(key)}>
|
|
731
|
+
Remove
|
|
732
|
+
</Button>
|
|
733
|
+
)}
|
|
734
|
+
</div>
|
|
635
735
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
<span>
|
|
640
|
-
<b>Created:</b> {created}
|
|
641
|
-
</span>
|
|
642
|
-
)}
|
|
643
|
-
{updated && (
|
|
644
|
-
<span>
|
|
645
|
-
<b>Updated:</b> {updated}
|
|
646
|
-
</span>
|
|
736
|
+
{/* Error state */}
|
|
737
|
+
{error && !isFetchingIssues && (
|
|
738
|
+
<div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--danger--color--100)' }}>{error}</div>
|
|
647
739
|
)}
|
|
648
|
-
</div>
|
|
649
|
-
|
|
650
|
-
<div style={{ height: 1, background: 'var(--pf-t--global--border--color--default)', marginTop: '0.25rem' }} />
|
|
651
740
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
<Title headingLevel="
|
|
657
|
-
|
|
741
|
+
{/* Issue details */}
|
|
742
|
+
{!error && issue && (
|
|
743
|
+
<>
|
|
744
|
+
{/* Title */}
|
|
745
|
+
<Title headingLevel="h3" size="lg" style={{ marginTop: '0.25rem' }}>
|
|
746
|
+
{summary || '—'}
|
|
658
747
|
</Title>
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
748
|
+
|
|
749
|
+
{/* Chips row */}
|
|
750
|
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.25rem' }}>
|
|
751
|
+
<Label color="grey" isCompact>
|
|
752
|
+
Status: {status || '—'}
|
|
753
|
+
</Label>
|
|
754
|
+
<Label color="orange" isCompact>
|
|
755
|
+
Priority: {priority || '—'}
|
|
756
|
+
</Label>
|
|
757
|
+
<Label color="grey" isCompact>
|
|
758
|
+
Assignee: {assignee || '—'}
|
|
759
|
+
</Label>
|
|
760
|
+
</div>
|
|
761
|
+
|
|
762
|
+
{/* Dates */}
|
|
763
|
+
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.875rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
|
|
764
|
+
{created && (
|
|
765
|
+
<span>
|
|
766
|
+
<b>Created:</b> {created}
|
|
767
|
+
</span>
|
|
768
|
+
)}
|
|
769
|
+
{updated && (
|
|
770
|
+
<span>
|
|
771
|
+
<b>Updated:</b> {updated}
|
|
772
|
+
</span>
|
|
773
|
+
)}
|
|
774
|
+
</div>
|
|
775
|
+
|
|
776
|
+
<div style={{ height: 1, background: 'var(--pf-t--global--border--color--default)', marginTop: '0.25rem' }} />
|
|
777
|
+
|
|
778
|
+
{/* Template sections (preferred) */}
|
|
779
|
+
{parsedSections.length > 0 ? (
|
|
780
|
+
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
781
|
+
<div>
|
|
782
|
+
<Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
783
|
+
Problem statement
|
|
784
|
+
</Title>
|
|
785
|
+
{renderBulletsOrText(byTitle.get('Problem statement') || '')}
|
|
786
|
+
</div>
|
|
787
|
+
<div>
|
|
788
|
+
<Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
789
|
+
Objective
|
|
790
|
+
</Title>
|
|
791
|
+
{renderBulletsOrText(byTitle.get('Objective') || '')}
|
|
792
|
+
</div>
|
|
793
|
+
<div>
|
|
794
|
+
<Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
795
|
+
Definition of Done
|
|
796
|
+
</Title>
|
|
797
|
+
{renderBulletsOrText(byTitle.get('Definition of Done') || '')}
|
|
798
|
+
</div>
|
|
799
|
+
</div>
|
|
800
|
+
) : (
|
|
801
|
+
// Fallback: show the raw description if it doesn't follow the template.
|
|
802
|
+
<div>
|
|
803
|
+
<Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
804
|
+
Description
|
|
805
|
+
</Title>
|
|
806
|
+
<div style={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap' }}>
|
|
807
|
+
{issue?.description ? stripHtmlTags(issue.description) : (
|
|
808
|
+
<span style={{ color: 'var(--pf-t--global--text--color--subtle)' }}>No description</span>
|
|
809
|
+
)}
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
683
812
|
)}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
813
|
+
</>
|
|
814
|
+
)}
|
|
815
|
+
</div>
|
|
816
|
+
</CardBody>
|
|
817
|
+
</Card>
|
|
818
|
+
);
|
|
819
|
+
})}
|
|
820
|
+
</div>
|
|
689
821
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
</div>
|
|
696
|
-
</CardBody>
|
|
697
|
-
</Card>
|
|
822
|
+
<div style={{ marginTop: '0.25rem' }}>
|
|
823
|
+
<Button variant="link" isDanger onClick={remove}>
|
|
824
|
+
Remove all Jira links
|
|
825
|
+
</Button>
|
|
826
|
+
</div>
|
|
698
827
|
</div>
|
|
699
828
|
);
|
|
700
829
|
};
|