create-pmos 0.142.0 → 0.144.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-initiatives-test.js +16 -3
- package/workspace/guide/console-calendar.md +20 -2
- package/workspace/guide/initiative-boards.md +2 -2
- package/workspace/guide/jira.md +15 -1
- package/workspace/guide/notes.md +2 -2
- package/workspace/lib/engine/boards.js +15 -5
- package/workspace/lib/jira-status.js +29 -9
- package/workspace/lib/jira-tickets.js +30 -2
- package/workspace/lib/routes-boards.js +61 -14
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +35 -4
- package/workspace/public/pm/c3-demo-boards.js +23 -4
- package/workspace/public/pm/c3-ib-items.js +3 -3
- package/workspace/public/pm/c3-ib-releases.js +2 -2
- package/workspace/public/pm/c3-ib-sprints.js +3 -3
- package/workspace/public/pm/c3-ii-tabs.js +8 -4
- package/workspace/public/pm/c3-ini-ui.js +2 -0
- package/workspace/public/pm/c3-initiatives.js +41 -12
- package/workspace/public/pm/c3-notes.js +316 -0
- package/workspace/public/pm/calendar-views.js +15 -1
- package/workspace/public/pm/console3.css +40 -0
- package/workspace/public/pm/shell-recording.js +7 -3
- package/workspace/skills/jira-sync.skill +6 -4
- package/workspace/test-lists/c3-notes-place.json +5 -0
|
@@ -21,6 +21,8 @@ import { el } from './dom.js';
|
|
|
21
21
|
import { c3ic } from './pm-work-view.js';
|
|
22
22
|
import { button, ensureComponentsCss } from './primitives.js';
|
|
23
23
|
import { toast } from './c3-toast.js';
|
|
24
|
+
import { notesView, openNote, noteOfEvent, primeNotes } from './c3-notes.js'; // Calendar › Notes (initiative c3-notes-place)
|
|
25
|
+
import { onNotes } from './notes-links.js';
|
|
24
26
|
|
|
25
27
|
const LAYERS = [
|
|
26
28
|
['meetings', 'Meetings'],
|
|
@@ -63,6 +65,8 @@ const setVar = (node, name, value) => { node.style.setProperty('--cal3-' + name,
|
|
|
63
65
|
function state(r) {
|
|
64
66
|
const st = r.page || (r.page = { on: new Set(LAYERS.map((l) => l[0]).filter((id) => !(r.ctx && r.ctx.overlay && mem.hide.includes(id)))), free: true, tidy: false, sel: null });
|
|
65
67
|
if (r.sel) { st.sel = { type: 'event', id: r.sel }; r.sel = null; } // a meeting chosen on the Briefing (openDay)
|
|
68
|
+
if (r.pendingMode) { st.mode = r.pendingMode; r.pendingMode = null; } // a note opened before the page drew (the Record chip)
|
|
69
|
+
if (!st.mode) st.mode = 'cal';
|
|
66
70
|
return st;
|
|
67
71
|
}
|
|
68
72
|
|
|
@@ -99,10 +103,21 @@ export function drawCalendarPage(host, r, h) {
|
|
|
99
103
|
ensureComponentsCss(); // the Tidy-up buttons are pmc-btn
|
|
100
104
|
const st = state(r);
|
|
101
105
|
const phone = r.root.classList.contains('ph');
|
|
106
|
+
const page = (b, ...kids) => el('div', { class: 'cal3' + (r.ctx.overlay ? ' ov' : ''), 'data-cal3': 'page', 'data-cal3-show': st.mode }, r.ctx.deck || null, b, ...kids);
|
|
107
|
+
const redraw0 = () => h.redraw(host, r);
|
|
108
|
+
const modes = r.ctx.overlay ? null : modeSeg(st, redraw0);
|
|
109
|
+
if (st.mode === 'notes' && modes) { // Calendar › Notes: the list and the open note; the zoom and the period have nothing to step here
|
|
110
|
+
return page(el('div', { class: 'calbar' }, el('div', { class: 'lens' }, modes)), notesView({ host, r, h, st, redraw: redraw0 }));
|
|
111
|
+
}
|
|
112
|
+
if (!r.notesOff) { // a meeting's "Open the note" needs the shared notes read; a new note redraws the open detail
|
|
113
|
+
r.notesOff = onNotes(() => { if (r.root.isConnected && st.mode === 'cal' && st.sel) h.redraw(host, r); });
|
|
114
|
+
primeNotes();
|
|
115
|
+
}
|
|
102
116
|
const bar = h.bar(host, r, phone);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
117
|
+
if (modes) { const lens = bar.querySelector('.lens'); if (lens) lens.prepend(modes); }
|
|
118
|
+
const pageBar = (...kids) => page(bar, ...kids);
|
|
119
|
+
if (r.error) return pageBar(fold('alert', 'The calendar could not be read: ' + r.error, { 'data-cv': 'error', role: 'status' }));
|
|
120
|
+
if (!r.data) return pageBar(fold('clock', 'Reading the repository', { 'data-cv': 'loading', role: 'status' }));
|
|
106
121
|
|
|
107
122
|
const d = r.data; const M = model(d, h);
|
|
108
123
|
const redraw = (focusSel) => { h.redraw(host, r); if (focusSel) { const n = r.root.querySelector(focusSel); if (n) n.focus(); } };
|
|
@@ -133,7 +148,22 @@ export function drawCalendarPage(host, r, h) {
|
|
|
133
148
|
el('div', { class: 'cal3-wrap' },
|
|
134
149
|
el('div', { class: 'cal3-main' }, empty, grid),
|
|
135
150
|
el('div', { class: 'cal3-side' }, detail(ui), st.tidy ? tidyList(ui) : null, st.free ? freeList(ui, range) : null, lanes(ui, range))));
|
|
136
|
-
return
|
|
151
|
+
return pageBar(chips(ui, range), body);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* "Open the note": the note recorded for this meeting, on Calendar › Notes; nothing when no note names the meeting */
|
|
155
|
+
function noteLink(ui, e) {
|
|
156
|
+
if (ui.r.ctx.overlay) return null;
|
|
157
|
+
const n = noteOfEvent(e); if (!n) return null;
|
|
158
|
+
return el('div', { class: 'cal3-ctx', 'data-cal3-ctx': 'note' }, el('h4', null, 'Notes'),
|
|
159
|
+
el('a', { href: '#', class: 'cal3-ref', 'data-cal3': 'open-note', 'data-cal3-open-note': n.id, title: 'Open the note: ' + (n.title || 'Untitled note'),
|
|
160
|
+
onClick: (ev) => { ev.preventDefault(); openNote(ui.r, n.id); ui.redraw(); } }, c3ic('notes', 'sm'), 'Open the note'));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* the Calendar | Notes switch, before the zoom (the mockup the PM approved on 2026-10-12) */
|
|
164
|
+
function modeSeg(st, redraw) {
|
|
165
|
+
const b = (id, icon, label) => { const x = button({ variant: 'ghost', attrs: { class: 'cal3-b', 'aria-pressed': String(st.mode === id), 'data-cal3-mode': id, title: label }, onClick: () => { if (st.mode !== id) { st.mode = id; redraw(); } } }); x.replaceChildren(c3ic(icon, 'sm'), el('span', null, label)); return x; };
|
|
166
|
+
return el('div', { class: 'seg', role: 'group', 'aria-label': 'Show', 'data-cal3': 'mode' }, b('cal', 'cal', 'Calendar'), b('notes', 'notes', 'Notes'));
|
|
137
167
|
}
|
|
138
168
|
|
|
139
169
|
function fold(icon, text, attrs) { return el('div', { class: 'fold', ...(attrs || {}) }, c3ic(icon, 'sm'), text); }
|
|
@@ -424,6 +454,7 @@ function meetingDetail(ui, hit) {
|
|
|
424
454
|
el('div', { class: 'dim mono cal3-when' }, h.dayLabel(hit.date) + (s === null ? ', all day' : ' · ' + h.hhmm(s) + ' to ' + h.hhmm(f))),
|
|
425
455
|
el('div', { class: 'chips2', 'data-cal3': 'meeting-chips' }, homeChip('Calendar'), ...peopleOf(e).map((n) => el('span', { class: 'tag' }, n)),
|
|
426
456
|
e.initiative ? el('span', { class: 'tag' }, 'Initiative: ' + (((ui.d.gantt || []).find((g) => g.id === e.initiative) || {}).title || e.initiative)) : null),
|
|
457
|
+
noteLink(ui, e),
|
|
427
458
|
...body,
|
|
428
459
|
el('p', { class: 'dim cal3-note' }, past ? 'Only what PMOS holds a record for: items created or closed within 48 hours of the meeting. Each line opens its source.' : 'PMOS proposes changes. It never edits your calendar.'),
|
|
429
460
|
];
|
|
@@ -211,7 +211,7 @@ const openAsks = (S, bid) => S.asks.filter((a) => a.board === bid && a.state ===
|
|
|
211
211
|
function rowOut(S, r) {
|
|
212
212
|
const wf = wfOf(S, r);
|
|
213
213
|
return { id: r.id, title: r.title, lane: r.lane, stage: r.stage, cat: catIn(wf, r.stage), owner: r.owner || null, anchor: r.anchor, horizon: r.horizon, status: r.status, start: r.start, end: r.end, progress: r.progress,
|
|
214
|
-
warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })) };
|
|
214
|
+
warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })), epics: (r.epics || []).slice(), epicsFrom: r.epics ? 'pmos' : null };
|
|
215
215
|
}
|
|
216
216
|
function boardOut(S, b) {
|
|
217
217
|
const list = S.rows.filter((r) => r.board === b.id);
|
|
@@ -219,7 +219,7 @@ function boardOut(S, b) {
|
|
|
219
219
|
for (const r of list) byCat[catOfRow(S, r)]++;
|
|
220
220
|
const rel = nextRelease(S, b.id);
|
|
221
221
|
return { ws: b.ws, id: b.id, name: b.name, full: b.full, domain: b.domain, template: b.template, templateEdited: tplEdited(b), workflow: b.workflow.map((s) => ({ ...s })), self: b.self, tracker: b.tracker, sprints: b.sprints,
|
|
222
|
-
build: b.build, buildDeliver: b.build, jira: b.jira, offer: b.offer.slice(), defTpl: b.defTpl, owner: b.owner, health: b.health,
|
|
222
|
+
build: b.build, buildDeliver: b.build, jira: b.jira, jiraProjects: b.build && b.jira ? (b.jiraProjects || [b.jira]).slice() : [], offer: b.offer.slice(), defTpl: b.defTpl, owner: b.owner, health: b.health,
|
|
223
223
|
counts: { total: list.length, open: list.filter(isOpen).length, byCat, risk: list.filter((r) => r.status === 'at risk').length, late: list.filter((r) => r.status === 'late').length },
|
|
224
224
|
asks: openAsks(S, b.id), nextRelease: rel ? { id: rel.id, name: rel.name, at: rel.date, date: rel.date, status: rel.status } : null };
|
|
225
225
|
}
|
|
@@ -396,7 +396,11 @@ export function handle(S, method, path, p, body) {
|
|
|
396
396
|
const v = {};
|
|
397
397
|
const bd = body.buildDeliver != null ? body.buildDeliver : body.build;
|
|
398
398
|
if (bd != null) { if (typeof bd !== 'boolean') return fail(400, 'buildDeliver is true or false'); v.build = bd; }
|
|
399
|
-
if (body.jira != null && body.jira !== '') {
|
|
399
|
+
if (body.jira != null && body.jira !== '') {
|
|
400
|
+
const keys = [].concat(body.jira).join(',').split(/[,\s]+/).filter(Boolean);
|
|
401
|
+
if (!keys.length || keys.some((j) => !/^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(j))) return fail(400, 'jira is one or more Jira project keys (at most 10), each a letter, then letters, digits or _ (at most 20)');
|
|
402
|
+
v.jiraProjects = [...new Set(keys.map((j) => j.toUpperCase()))]; v.jira = v.jiraProjects[0];
|
|
403
|
+
}
|
|
400
404
|
const build = 'build' in v ? v.build : b.build;
|
|
401
405
|
if (body.offer != null) {
|
|
402
406
|
if (!Array.isArray(body.offer) || !body.offer.length || body.offer.some((k) => !INI_TEMPLATES[k])) return fail(400, 'offer is a list of templates: ' + Object.keys(INI_TEMPLATES).join(', '));
|
|
@@ -413,7 +417,8 @@ export function handle(S, method, path, p, body) {
|
|
|
413
417
|
Object.assign(b, v);
|
|
414
418
|
if (!b.build) { b.offer = b.offer.filter((k) => !templateBuilds(k)); if (!b.offer.length) b.offer = ['simple']; if (!b.offer.includes(b.defTpl)) b.defTpl = b.offer[0]; }
|
|
415
419
|
if (b.build && !b.jira) b.jira = b.prefix;
|
|
416
|
-
|
|
420
|
+
if (!b.jiraProjects || !b.jiraProjects.includes(b.jira)) b.jiraProjects = b.jira ? [b.jira] : [];
|
|
421
|
+
b.tracker = b.build && b.jira ? 'Jira · ' + b.jiraProjects.join(', ') : null; b.sprints = !!(b.build && b.jira);
|
|
417
422
|
S.events.push({ at: at(0, 8), by: 'You', kind: 'board', op: 'settings', board: b.id, ...v });
|
|
418
423
|
return okb({ board: boardOut(S, b) });
|
|
419
424
|
}
|
|
@@ -441,6 +446,20 @@ export function handle(S, method, path, p, body) {
|
|
|
441
446
|
S.events.push({ at: at(0, 8), by: 'You', kind: 'board-workflow', board: b.id, stages: b.workflow, moved: mr });
|
|
442
447
|
return okb({ board: boardOut(S, b), moved });
|
|
443
448
|
}
|
|
449
|
+
if (path === '/api/boards/ini-epics') {
|
|
450
|
+
if (!b.build) return fail(409, b.name + ' has Build and deliver off, so it mirrors no Jira');
|
|
451
|
+
const r = S.rows.find((x) => x.id === body.id && x.board === b.id);
|
|
452
|
+
if (!r) return fail(404, 'no initiative ' + (body.id || '') + ' on this board');
|
|
453
|
+
const keys = Array.isArray(body.epics) ? body.epics : [];
|
|
454
|
+
if (keys.length > 20 || keys.some((k) => !/^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(String(k)))) return fail(400, 'epics is a list of Jira keys (at most 20), each like IDPF-2685');
|
|
455
|
+
const epics = [...new Set(keys.map((k) => String(k).toUpperCase()))];
|
|
456
|
+
const projects = b.jiraProjects && b.jiraProjects.length ? b.jiraProjects : b.jira ? [b.jira] : [];
|
|
457
|
+
const off = projects.length ? epics.filter((k) => !projects.includes(k.split('-')[0])) : [];
|
|
458
|
+
if (off.length) return fail(400, off.join(', ') + (off.length === 1 ? ' is' : ' are') + ' not in a Jira project of ' + b.name + ' (' + projects.join(', ') + ')');
|
|
459
|
+
r.epics = epics;
|
|
460
|
+
S.events.push({ at: at(0, 8), by: 'You', kind: 'ini-epics', board: b.id, id: r.id, epics });
|
|
461
|
+
return okb({ row: rowOut(S, r) });
|
|
462
|
+
}
|
|
444
463
|
if (path === '/api/boards/ini-workflow') {
|
|
445
464
|
if (b.self) return fail(409, 'The PMOS board follows the ladder its records derive.');
|
|
446
465
|
const r = S.rows.find((x) => x.id === body.id && x.board === b.id);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Every control is a button; the drag handlers are drag events on the cards and columns (pointer handlers stay on the document). */
|
|
15
15
|
import { el } from './dom.js';
|
|
16
16
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
17
|
-
import { icon, pill, lk, ib, pbtn, note, lbl, sect, card, list, empty, stepsMini, plural, trunc, openMenu, mono } from './c3-ini-ui.js';
|
|
17
|
+
import { icon, pill, lk, ib, pbtn, note, lbl, sect, card, list, empty, stepsMini, jiraLabel, plural, trunc, openMenu, mono } from './c3-ini-ui.js';
|
|
18
18
|
import { STATUSES, wfOf, tplName, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
|
|
19
19
|
import { timeline, tlInit, tlRange, tlStill } from './c3-ini-timeline.js';
|
|
20
20
|
|
|
@@ -67,7 +67,7 @@ function mountItems(tab) {
|
|
|
67
67
|
others > 0 ? el('span', { class: 'dim ini-note' }, plural(others, 'more initiative has', 'more initiatives have') + ' no epics or tasks yet') : null),
|
|
68
68
|
b.build
|
|
69
69
|
? el('div', { class: 'chips2', role: 'group', 'aria-label': 'Source filter', 'data-wk': 'srcs' }, lbl('SOURCE'),
|
|
70
|
-
...[['', 'All sources'], ['jira', [icon('jira', 'sm'), 'Jira ' + (b
|
|
70
|
+
...[['', 'All sources'], ['jira', [icon('jira', 'sm'), 'Jira ' + jiraLabel(b)]], ['pmos', 'PMOS tasks']].map(([v, l]) => pill({ 'data-wk-src': v, 'aria-pressed': String(st.src === v), onclick: () => { st.src = v; draw('[data-wk-src="' + v + '"]'); } }, ...[].concat(l))))
|
|
71
71
|
: el('div', { class: 'dim ini-note', 'data-wk': 'no-jira' }, icon('info', 'sm'), ' Build and deliver is off for ' + b.name + ', so there is no Jira here: every item is a PMOS task. ', lk({ onclick: () => ctx.openBoardSettings() }, 'Board settings')),
|
|
72
72
|
el('div', { class: 'dim ini-note', 'data-wk': 'count' }, plural(n, 'item') + (ne ? ' in ' + plural(ne, 'epic') : '') + (st.ini ? ' · ' + st.ini + ' only' : '') + (st.src ? ' · ' + (st.src === 'jira' ? 'Jira only' : 'PMOS only') : '') + ' · ',
|
|
73
73
|
any ? lk({ 'data-wk': 'clear', onclick: () => { Object.assign(st, { q: '', ini: '', src: '', kind: '', hideDone: false }); draw(); } }, 'Clear filters') : null, any ? ' · ' : null, 'same filters on Backlog, Board and Timeline'),
|
|
@@ -178,7 +178,7 @@ function mountItems(tab) {
|
|
|
178
178
|
requestAnimationFrame(() => { if (!dead) tlInit(host); });
|
|
179
179
|
return [sect({ ic: 'cal', title: 'Timeline', count: rows.length, right: mono('press a row to open its epics') }, node,
|
|
180
180
|
hidden > 0 ? el('div', { class: 'chips2' }, el('span', { class: 'dim ini-note' }, plural(hidden, 'more Now initiative', 'more Now initiatives') + ' with no epics yet'), pill({ 'data-wk': 'tl-all', onclick: () => { st.tlAll = true; draw(); } }, 'Show all')) : null,
|
|
181
|
-
b.build ? note('Release dates come from Jira ' + (b
|
|
181
|
+
b.build ? note('Release dates come from Jira ' + jiraLabel(b) + ' fix versions. PMOS bars drag to reschedule; Jira bars are locked.') : note('Every bar here is a PMOS task: drag it to reschedule.'))];
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
function draw(focusSel) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* its id, one with no date says "No date yet". */
|
|
9
9
|
import { el } from './dom.js';
|
|
10
10
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
11
|
-
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, plural, day, daysBetween } from './c3-ini-ui.js';
|
|
11
|
+
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, jiraLabel, plural, day, daysBetween } from './c3-ini-ui.js';
|
|
12
12
|
import { loadItems } from './c3-ini-work.js';
|
|
13
13
|
|
|
14
14
|
const whenOf = (r) => String(r.release_at || r.deploy_at || r.date || '').slice(0, 10);
|
|
@@ -45,7 +45,7 @@ registerBoardTab('releases', async (host, ctx) => {
|
|
|
45
45
|
host.replaceChildren(cols(
|
|
46
46
|
[sect({ ic: 'out', title: 'Unreleased', count: up.length, attrs: { 'data-inb': 'release-next' } }, up.length ? list(up.map((r) => row(r, false))) : empty('No release planned for ' + b.name + '.', { 'data-inb': 'release-none-next' })),
|
|
47
47
|
past.length ? sect({ ic: 'check', title: 'Released', count: past.length, attrs: { 'data-inb': 'release-past' } }, list(past.map((r) => row(r, true)))) : null],
|
|
48
|
-
[card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + (b
|
|
48
|
+
[card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + jiraLabel(b) + ' fix versions, because this board builds and delivers. Release dates are references: their home is the Calendar.'),
|
|
49
49
|
el('div', { class: 'chips2' }, pbtn('outline', 'Open the Calendar', 'cal', { 'data-inb': 'open-calendar', onclick: () => ctx.showTab('cal') })))]));
|
|
50
50
|
return () => { dead = true; };
|
|
51
51
|
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the stage-records change; when that route is not on this server yet, the card says so quietly). source 'none' says there is no tracker. */
|
|
8
8
|
import { el } from './dom.js';
|
|
9
9
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
10
|
-
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, plural, day } from './c3-ini-ui.js';
|
|
10
|
+
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, jiraLabel, plural, day } from './c3-ini-ui.js';
|
|
11
11
|
import { loadItems } from './c3-ini-work.js';
|
|
12
12
|
import { recordCards } from './c3-ini-records.js';
|
|
13
13
|
|
|
@@ -22,7 +22,7 @@ registerBoardTab('sprints', async (host, ctx) => {
|
|
|
22
22
|
const [r, items] = await Promise.all([ctx.api.get('/api/boards/sprints', { board: b.id }), loadItems(ctx)]);
|
|
23
23
|
if (dead) return () => {};
|
|
24
24
|
if (!r || !r.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The sprints could not be read: ' + ((r && r.error) || 'no answer') + '.')); return () => { dead = true; }; }
|
|
25
|
-
if (r.source === 'none') { host.replaceChildren(empty('Waiting for the first sync from Jira ' + (b
|
|
25
|
+
if (r.source === 'none') { host.replaceChildren(empty('Waiting for the first sync from Jira ' + jiraLabel(b) + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
|
|
26
26
|
const sprints = r.sprints || [];
|
|
27
27
|
const active = sprints.find((s) => s.state === 'active');
|
|
28
28
|
const others = sprints.filter((s) => s !== active);
|
|
@@ -32,7 +32,7 @@ registerBoardTab('sprints', async (host, ctx) => {
|
|
|
32
32
|
right: [el('span', { class: 'chip2', title: 'Status lives in Jira' }, icon('lock', 'sm'))], attrs: { 'data-inb-issue': i.key } });
|
|
33
33
|
const t = readTime(r.readAt);
|
|
34
34
|
const left = [
|
|
35
|
-
el('div', { class: 'fold', 'data-inb': 'sprints-source' }, icon('lock', 'sm'), el('b', null, 'Mirrored from Jira, read-only.'), ' ' + (t ? 'Read ' + t + ' from Jira ' + (b
|
|
35
|
+
el('div', { class: 'fold', 'data-inb': 'sprints-source' }, icon('lock', 'sm'), el('b', null, 'Mirrored from Jira, read-only.'), ' ' + (t ? 'Read ' + t + ' from Jira ' + jiraLabel(b) + '.' : 'Not read yet.') + ' Change sprints in Jira.'),
|
|
36
36
|
active ? sect({ ic: 'check', title: active.name + ' items', count: (active.issues || []).length, right: tag('read-only'), attrs: { 'data-inb-sprint': active.name, 'data-inb-state': 'active' } },
|
|
37
37
|
(active.issues || []).length ? list(active.issues.map(issueRow)) : empty('No items in this sprint yet.', { 'data-inb': 'sprint-no-issues' }))
|
|
38
38
|
: empty(sprints.length ? 'No sprint is active.' : 'Jira has no sprints for this board.', { 'data-inb': sprints.length ? 'sprints-no-active' : 'sprints-empty' }),
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Every tab has an honest empty state and an error line when its read fails. */
|
|
15
15
|
import { el } from './dom.js';
|
|
16
16
|
import { registerInitiativeTab } from './c3-initiatives.js';
|
|
17
|
-
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, plural, trunc, day, dayLong, ago, PORTFOLIO } from './c3-ini-ui.js';
|
|
17
|
+
import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, jiraLabel, plural, trunc, day, dayLong, ago, PORTFOLIO } from './c3-ini-ui.js';
|
|
18
18
|
import { wfOf, catOf, kindOf, tplName, toolLabel, toolIcon, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
|
|
19
19
|
import { recordCards, recordToolsFor, RECORD_META } from './c3-ini-records.js';
|
|
20
20
|
import { loadDocs, renderDocs } from './c3-ib-docs.js';
|
|
@@ -36,6 +36,10 @@ function tab(id, build) {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
const mine = (ctx, items) => items.filter((w) => w.ini === ctx.row.id);
|
|
39
|
+
/** Why an initiative shows no Jira item yet: no epic linked, or its epics not read yet. */
|
|
40
|
+
const epicsLine = (ctx) => ((ctx.row.epics || []).length
|
|
41
|
+
? 'No Jira item read yet for ' + ctx.row.epics.join(', ') + '. The daily Jira check reads each epic and the issues under it.'
|
|
42
|
+
: 'No Jira epic is linked to ' + ctx.row.id + ' yet. Press Link Jira epics above to choose its epics' + (jiraLabel(ctx.board) ? ' in Jira ' + jiraLabel(ctx.board) : '') + '.');
|
|
39
43
|
|
|
40
44
|
/* ── Overview ── */
|
|
41
45
|
async function toolCard(ctx, t, R, rels) {
|
|
@@ -48,7 +52,7 @@ async function toolCard(ctx, t, R, rels) {
|
|
|
48
52
|
case 'tickets': {
|
|
49
53
|
const tk = its.filter((w) => w.src === 'jira');
|
|
50
54
|
if (!b.build) body = [note(b.name + ' has Build and deliver off, so there is no Jira. Progress comes from PMOS tasks: ' + by('Done') + ' of ' + its.length + ' done.')];
|
|
51
|
-
else if (!tk.length) body = [note(
|
|
55
|
+
else if (!tk.length) body = [note(epicsLine(ctx))];
|
|
52
56
|
else {
|
|
53
57
|
const d = tk.filter((w) => w.status === 'Done').length; const p = tk.filter((w) => /progress|review/i.test(w.status)).length; const td = tk.length - d - p;
|
|
54
58
|
body = [hbs([hbr('Done', d, d / tk.length, 'ok'), hbr('In progress', p, p / tk.length, 'acc'), hbr('To do', td, td / tk.length, 'pm')]),
|
|
@@ -129,7 +133,7 @@ tab('overview', async (ctx, isDead, host) => {
|
|
|
129
133
|
['Owner', ownerText(r.owner)], ['Anchor', r.anchor || 'No anchor'], ['Horizon', r.horizon ? r.horizon[0].toUpperCase() + r.horizon.slice(1) : 'Not set'], ['Lane', r.lane || 'Not set']]),
|
|
130
134
|
hbs([hbr('Progress', Math.round(p * 100) + '%', p, toneOf(r.status))]),
|
|
131
135
|
el('div', { class: 'chips2', 'data-ini': 'board-links' }, ...['backlog', 'board', 'timeline'].map((k) => lk({ 'data-ini-boardlink': k, onclick: () => ctx.openBoard(b.id, k, { ini: r.id }) }, icon('next', 'sm'), b.name + ' ' + k[0].toUpperCase() + k.slice(1) + ', this initiative only')))),
|
|
132
|
-
card({ ic: 'bolt', title: 'Work', sub: b.build ? 'Jira ' + (b
|
|
136
|
+
card({ ic: 'bolt', title: 'Work', sub: b.build ? 'Jira ' + jiraLabel(b) + ' and PMOS' : 'PMOS tasks' },
|
|
133
137
|
note(plural(work.filter((w) => w.kind === 'Epic').length, 'epic') + ', ' + plural(work.filter((w) => w.kind !== 'Epic').length, 'item') + '.'), el('div', { class: 'chips2' }, lk({ onclick: () => ctx.go({ view: 'ini', board: b.id, id: r.id, tab: 'breakdown' }) }, icon('next', 'sm'), 'Open breakdown'))),
|
|
134
138
|
rels.length ? card({ ic: 'out', title: 'Releases', sub: 'live in the Calendar' }, chgl(rels.map((x) => el('li', null, icon('out', 'sm'), el('span', null, (x.name || x.id) + ' · ' + day(String(x.release_at || x.deploy_at || '').slice(0, 10))), tag('Calendar'))))) : null,
|
|
135
139
|
];
|
|
@@ -196,7 +200,7 @@ tab('tickets', async (ctx, isDead) => {
|
|
|
196
200
|
if (isDead()) return null;
|
|
197
201
|
if (!R.ok) return errLine('The Jira items could not be read: ' + R.error + '.');
|
|
198
202
|
const tk = mine(ctx, R.items).filter((w) => w.src === 'jira');
|
|
199
|
-
if (!tk.length) return empty(
|
|
203
|
+
if (!tk.length) return empty(epicsLine(ctx), { 'data-inb': 'tk-empty' });
|
|
200
204
|
const its = tk.filter((w) => w.kind !== 'Epic'); const n = Math.max(1, its.length);
|
|
201
205
|
const c = (s) => its.filter((w) => w.status === s).length;
|
|
202
206
|
return cols(
|
|
@@ -75,6 +75,8 @@ export function geo(node, vars) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/* ── text bits ── */
|
|
78
|
+
/** The Jira projects a board mirrors, for a sentence: "IDPF, LB" (a board may map several Jira projects). */
|
|
79
|
+
export const jiraLabel = (b) => ((b && Array.isArray(b.jiraProjects) && b.jiraProjects.length) ? b.jiraProjects.join(', ') : (b && b.jira) || '');
|
|
78
80
|
export const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many || one + 's');
|
|
79
81
|
export const trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
80
82
|
export const chip = (...kids) => el('span', { class: 'chip2' }, ...kids);
|
|
@@ -42,7 +42,7 @@ import { ensureC3Css } from './pm-work-view.js';
|
|
|
42
42
|
import { openSheet, closeSheet } from './c3-sheet.js';
|
|
43
43
|
import { toast } from './c3-toast.js';
|
|
44
44
|
import {
|
|
45
|
-
btn, geo, icon, pbtn, pill, lk, ib, chip, tag, st, note, lbl, mono, sw, plural, trunc, day, daysBetween, statusChip, healthSt, HEALTH, ownerChip, ownerText,
|
|
45
|
+
btn, geo, icon, pbtn, pill, lk, ib, chip, tag, st, note, lbl, mono, sw, jiraLabel, plural, trunc, day, daysBetween, statusChip, healthSt, HEALTH, ownerChip, ownerText,
|
|
46
46
|
stageChip, stepsMini, progChip, dateChip, list, empty, sect, card, nrow, crumbs, lh, segTabs, cols, hbs, hbr, kv, chgl, chgli, openMenu, closeMenu, CATS, PORTFOLIO,
|
|
47
47
|
} from './c3-ini-ui.js';
|
|
48
48
|
import { TEMPLATES, BOARD_TEMPLATES, isBuildStage, templateBuilds, offered, wfOf, catOf, kindOf, tplName, TOOL_META, toolLabel, toolIcon, loadItems, dropItems, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
|
|
@@ -107,7 +107,7 @@ function ensureCss() {
|
|
|
107
107
|
document.head.appendChild(l);
|
|
108
108
|
}
|
|
109
109
|
const warnChip = (w) => st('warn', 'alert', w, { title: 'An order rule, shown as a warning. It never blocks.', 'data-ini-warning': w });
|
|
110
|
-
const buildTag = (b) => (b.build ? el('span', { class: 'tag' }, icon('jira', 'sm'), b
|
|
110
|
+
const buildTag = (b) => (b.build ? el('span', { class: 'tag' }, icon('jira', 'sm'), jiraLabel(b) ? 'Jira · ' + jiraLabel(b) : 'Jira not set') : el('span', { class: 'tag' }, 'PMOS tasks only'));
|
|
111
111
|
const errLine = (text) => el('div', { class: 'fold w', role: 'alert', 'data-ini': 'error' }, icon('alert', 'sm'), text);
|
|
112
112
|
const sentenceOf = (bad, warn) => (bad.length || warn.length
|
|
113
113
|
? [bad.length ? plural(bad.length, 'board') + (bad.length === 1 ? ' is' : ' are') + ' off track' : '', warn.length ? plural(warn.length, 'board') + ' need' + (warn.length === 1 ? 's' : '') + ' attention' : ''].filter(Boolean).join(', ')
|
|
@@ -271,7 +271,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
271
271
|
const s = pick.map((i) => i.start).sort()[0]; const e = pick.map((i) => i.end).sort().pop();
|
|
272
272
|
const avg = pick.reduce((n, i) => n + (Number(i.progress) || 0), 0) / pick.length;
|
|
273
273
|
rows.push({ uid: 'b:' + b.id, label: b.name + ' · ' + pick.length + ' of ' + now.length, start: s, end: e, progress: avg, cls: 'grp', tone: b.health === 'bad' ? 'bad' : b.health === 'warn' ? 'warn' : 'ok',
|
|
274
|
-
open: () => openBoard(b.id, 'timeline'), tip: { t: b.name + (b.full ? ' · ' + b.full : ''), p: Math.round(avg * 100) + '%', st: HEALTH[b.health] || 'No health yet', o: b.owner || '', src: b.build ? 'Board · Jira ' + (b
|
|
274
|
+
open: () => openBoard(b.id, 'timeline'), tip: { t: b.name + (b.full ? ' · ' + b.full : ''), p: Math.round(avg * 100) + '%', st: HEALTH[b.health] || 'No health yet', o: b.owner || '', src: b.build ? 'Board · Jira ' + jiraLabel(b) : 'Board · PMOS tasks only', hint: 'Click the row to fold the board' },
|
|
275
275
|
kids: pick.map((i) => iniTlRow(ctx, i, workTlRows(ctx, r.items.filter((w) => w.ini === i.id), () => { dropItems(ctx); draw(); }), b)) });
|
|
276
276
|
}
|
|
277
277
|
const { from, to } = tlRange(P.today);
|
|
@@ -318,7 +318,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
318
318
|
lh('cols', b.name + (b.full ? ' · ' + b.full : ''), (HEALTH[b.health] || 'No health yet') + (risk ? ', ' + risk + ' at risk or late' : '') + (asks ? ', ' + plural(asks, 'new ask') + ' to triage' : '') + '. '
|
|
319
319
|
+ plural(c.total || 0, 'initiative') + (items.ok ? ', ' + plural(work.filter((w) => w.kind === 'Epic').length, 'epic') + ', ' + plural(work.filter((w) => w.kind !== 'Epic').length, 'item') : '') + '.'),
|
|
320
320
|
el('div', { class: 'chips2', 'aria-label': 'Board setup', 'data-ini': 'setup' },
|
|
321
|
-
...(b.build ? [st('ok', 'bolt', 'Build and deliver on'), chip(icon('jira', 'sm'), b.
|
|
321
|
+
...(b.build ? [st('ok', 'bolt', 'Build and deliver on'), chip(icon('jira', 'sm'), jiraLabel(b) ? [(b.jiraProjects || []).length > 1 ? 'Jira projects ' : 'Jira project ', el('b', { class: 'mono' }, jiraLabel(b)), ' · ' + plural(jiraN, 'item')] : 'No Jira project set'), chip('PMOS tasks ' + pmN)]
|
|
322
322
|
: [chip(icon('x', 'sm'), 'Build and deliver off'), chip('No Jira · ' + plural(pmN, 'PMOS task'))]),
|
|
323
323
|
chip(icon('flag', 'sm'), 'New initiatives start with ' + def.name), lk({ onclick: () => openBoardSettings(b), 'data-ini': 'setup-change' }, 'Change')),
|
|
324
324
|
b.self ? note(SELF_WHY) : null,
|
|
@@ -356,7 +356,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
356
356
|
[card({ ic: 'flag', title: 'Where the initiatives are', sub: 'each on its own workflow' },
|
|
357
357
|
hbs(CATS.map((c) => btn({ class: 'ini-hbb', 'data-ini-catgo': c, title: 'See ' + c + ' initiatives', onclick: () => goIni({ view: 'all', cat: c }) }, hbr([sw(c), ' ', c], byCat(c), byCat(c) / maxN, 'acc', { class: 'hbr ini-c', 'data-cat': c })))),
|
|
358
358
|
note('Stages roll up into four categories. Press one to list its initiatives.')),
|
|
359
|
-
card({ ic: 'todo', title: 'Work items', sub: b.build ? 'Jira ' + (b
|
|
359
|
+
card({ ic: 'todo', title: 'Work items', sub: b.build ? 'Jira ' + jiraLabel(b) + ' and PMOS' : 'PMOS tasks' },
|
|
360
360
|
hbs(['To do', 'In progress', 'In review', 'Done'].map((s) => hbr(s, nSt(s), nSt(s) / tot, s === 'Done' ? 'ok' : s === 'To do' ? 'pm' : 'acc'))),
|
|
361
361
|
el('div', { class: 'chips2' }, lk({ onclick: () => ctx.openBoard(b.id, 'board') }, 'Open the Board'), lk({ onclick: () => ctx.openBoard(b.id, 'timeline') }, 'Open the Timeline'))),
|
|
362
362
|
card({ ic: 'report', title: 'At a glance' }, chgl([
|
|
@@ -381,7 +381,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
381
381
|
|
|
382
382
|
function openBoardSettings(b) {
|
|
383
383
|
if (b.self) { openSheet({ title: 'Board settings · ' + b.name, icon: 'lock', body: el('div', { class: 'ini-form' }, note(SELF_WHY)) }); return; }
|
|
384
|
-
const B = { build: b.build !== false, jira: b
|
|
384
|
+
const B = { build: b.build !== false, jira: jiraLabel(b), offer: new Set(offered(b)), def: b.defTpl || offered(b)[0], err: '', busy: false };
|
|
385
385
|
const host = el('div', { class: 'ini-form', 'data-ini': 'settings-form' });
|
|
386
386
|
const sh = openSheet({ title: 'Board settings · ' + b.name, icon: 'cols', body: host, wide: true });
|
|
387
387
|
let usage = null;
|
|
@@ -394,11 +394,11 @@ export function mountInitiatives(pane, opts) {
|
|
|
394
394
|
const inis = usage && usage.ok ? usage.initiatives : [];
|
|
395
395
|
const jiraN = usage && usage.ok ? usage.items.filter((w) => w.src === 'jira').length : 0;
|
|
396
396
|
const usingBuild = inis.filter((i) => wfOf(i, b).some(isBuildStage)).length;
|
|
397
|
-
const jira = el('input', { type: 'text', value: B.jira, 'aria-label': 'Jira project
|
|
397
|
+
const jira = el('input', { type: 'text', value: B.jira, 'aria-label': 'Jira project keys', placeholder: 'Project keys, e.g. IDPF, LB', 'data-ini': 'jira-key', oninput: (e) => { B.jira = e.target.value; } });
|
|
398
398
|
host.replaceChildren(
|
|
399
399
|
note('Settings for the board. Workflows belong to each initiative; here you choose which templates the board offers and which one new initiatives start with.'),
|
|
400
400
|
checkbox(B.build, [el('b', null, 'Build and deliver'), ': this board builds a product and mirrors Jira'], { 'data-ini': 'build' }, (e) => { B.build = e.target.checked; if (B.build) Object.keys(TEMPLATES).filter(templateBuilds).forEach((k) => B.offer.add(k)); render('[data-ini="build"]'); }),
|
|
401
|
-
B.build ? el('div', { class: 'ini-field' }, lbl('JIRA
|
|
401
|
+
B.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECTS'), el('div', { class: 'form' }, jira), note('One or more Jira projects, separated by commas. Shows their epics, stories, tasks and bugs next to PMOS tasks, and adds the Sprints and Releases tabs (mirrored, read-only). Link each initiative to its Jira epics on the initiative page.'))
|
|
402
402
|
: note('Off: Backlog, Board and Timeline show PMOS tasks only. No Sprints or Releases tab, and initiatives choose workflows without Build and Deliver.'),
|
|
403
403
|
!B.build && b.build ? el('div', { class: 'fold w', role: 'alert', 'data-ini': 'build-off-warning' }, icon('alert', 'sm'), el('span', null, plural(jiraN, 'Jira item') + ' will be hidden from this board.' + (usingBuild ? ' ' + plural(usingBuild, 'initiative') + ' use Build or Deliver stages; they keep them until you edit their workflow.' : ''))) : null,
|
|
404
404
|
lbl('WORKFLOWS THIS BOARD OFFERS'),
|
|
@@ -416,16 +416,16 @@ export function mountInitiatives(pane, opts) {
|
|
|
416
416
|
}
|
|
417
417
|
async function save() {
|
|
418
418
|
const key = B.jira.trim();
|
|
419
|
-
if (B.build && key &&
|
|
419
|
+
if (B.build && key && !key.split(/[,\s]+/).filter(Boolean).every((k) => /^[A-Za-z][A-Za-z0-9_]{0,19}$/.test(k))) { B.err = 'Each Jira project key is a letter, then letters, digits or _. Separate keys with a comma.'; render(); return; }
|
|
420
420
|
B.busy = true; B.err = ''; render();
|
|
421
421
|
const body = { board: b.id, buildDeliver: B.build, offer: [...B.offer], defTpl: B.def };
|
|
422
|
-
if (B.build
|
|
422
|
+
if (B.build) body.jira = key;
|
|
423
423
|
const r = await apiFor(b.ws).post('/api/boards/settings', body);
|
|
424
424
|
B.busy = false;
|
|
425
425
|
if (!r || !r.ok) { B.err = 'The settings were not saved: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
|
|
426
426
|
sh.close();
|
|
427
427
|
const nb = r.board || {};
|
|
428
|
-
toast(b.name + ': Build and deliver ' + (nb.build ? 'on' + (nb
|
|
428
|
+
toast(b.name + ': Build and deliver ' + (nb.build ? 'on' + (jiraLabel(nb) ? ', mirroring Jira ' + jiraLabel(nb) : '') : 'off, PMOS tasks only') + '. New initiatives start with ' + (TEMPLATES[nb.defTpl] || TEMPLATES[B.def]).name + '.');
|
|
429
429
|
refresh();
|
|
430
430
|
}
|
|
431
431
|
render();
|
|
@@ -445,7 +445,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
445
445
|
lbl('BOARD NAME'), el('div', { class: 'form' }, name),
|
|
446
446
|
wsList.length > 1 ? el('label', { class: 'ini-field' }, lbl('WORKSPACE'), el('select', { 'data-ini': 'nb-ws', onchange: (e) => { N.ws = e.target.value; } }, ...wsList.map((w) => el('option', { value: w.id, selected: w.id === N.ws }, w.name || w.id)))) : null,
|
|
447
447
|
checkbox(N.build, [el('b', null, 'Build and deliver'), ': this board builds a product and mirrors Jira'], { 'data-ini': 'nb-build' }, (e) => { N.build = e.target.checked; render('[data-ini="nb-build"]'); }),
|
|
448
|
-
N.build ? el('div', { class: 'ini-field' }, lbl('JIRA
|
|
448
|
+
N.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECTS'), el('div', { class: 'form' }, el('input', { type: 'text', value: N.jira, placeholder: 'Project keys, e.g. PP, PPM', 'aria-label': 'Jira project keys', 'data-ini': 'nb-jira', oninput: (e) => { N.jira = e.target.value; } })),
|
|
449
449
|
note('Epics, stories, tasks and bugs from this project show next to PMOS tasks. Sprints and releases are mirrored, read-only.'))
|
|
450
450
|
: note('Off: no Jira. Initiatives here use workflows without Build and Deliver, and the work is PMOS tasks. Good for a microsite run by an agency, an office move or hiring.'),
|
|
451
451
|
lbl('DEFAULT WORKFLOW FOR NEW INITIATIVES'), tplSeg(allowed, N.tpl, (k) => { N.tpl = k; render('[data-ini-tpl="' + k + '"]'); }, 'Default workflow'), tplCase(N.tpl),
|
|
@@ -690,6 +690,7 @@ export function mountInitiatives(pane, opts) {
|
|
|
690
690
|
el('div', { class: 'calbody', 'data-ini-page': row.id },
|
|
691
691
|
lh('target', row.title || row.id, [row.id, bb.name, row.anchor || 'no anchor', row.owner || 'no owner', row.start || row.end ? day(row.start) + ' to ' + day(row.end) : 'no dates yet', row.horizon ? row.horizon[0].toUpperCase() + row.horizon.slice(1) : '', Math.round((Number(row.progress) || 0) * 100) + '% done'].filter(Boolean).join(' · ')),
|
|
692
692
|
el('div', { class: 'chips2', 'data-ini': 'ini-chips' }, statusChip(row.status), chip(icon('bolt', 'sm'), plural(ep, 'epic') + ', ' + plural(work.length - ep, 'item')), buildTag(bb),
|
|
693
|
+
bb.build ? pill({ 'data-ini': 'jira-epics', 'aria-haspopup': 'dialog', title: 'The Jira epics of this initiative', onclick: () => openEpics(bb, row) }, icon('jira', 'sm'), (row.epics || []).length ? 'Jira epics ' + row.epics.join(', ') : 'Link Jira epics') : null,
|
|
693
694
|
row.lane ? tag(row.lane) : null, el('span', { class: 'chips2', 'data-ini': 'warnings', 'aria-live': 'polite' }, ...warnings.map(warnChip))),
|
|
694
695
|
stepper, bb.self ? note(SELF_WHY) : null, strip,
|
|
695
696
|
segTabs(tabs, tab, (k) => go({ view: 'ini', board: bb.id, id: row.id, tab: k }), 'Initiative views', { 'data-ini': 'ini-tabs' }),
|
|
@@ -697,6 +698,34 @@ export function mountInitiatives(pane, opts) {
|
|
|
697
698
|
if (P.focusStep) { const f = stepper.querySelector('[data-ini-step="' + CSS.escape(P.focusStep) + '"]'); P.focusStep = null; if (f) f.focus(); }
|
|
698
699
|
mountTab(host, null, (INI_TAB_LIST.find((t) => t[0] === tab) || [])[1], ctxI, token);
|
|
699
700
|
}
|
|
701
|
+
/* The Jira epics of an initiative: the daily Jira check reads each one and the issues under it, read-only. */
|
|
702
|
+
function openEpics(b, row) {
|
|
703
|
+
const E = { text: (row.epics || []).join(', '), err: '', busy: false };
|
|
704
|
+
const host = el('div', { class: 'ini-form', 'data-ini': 'epics-form' });
|
|
705
|
+
const sh = openSheet({ title: 'Jira epics · ' + row.id, icon: 'bolt', body: host });
|
|
706
|
+
function render() {
|
|
707
|
+
const input = el('input', { type: 'text', value: E.text, 'aria-label': 'Jira epic keys', placeholder: 'Epic keys, e.g. IDPF-2685, LB-120', 'data-ini': 'epics-input', oninput: (e) => { E.text = e.target.value; } });
|
|
708
|
+
host.replaceChildren(
|
|
709
|
+
note('The Jira epics this initiative runs on. The daily Jira check reads each epic and the issues under it, read-only, and shows them as its Jira items.' + (jiraLabel(b) ? ' ' + b.name + ' mirrors Jira ' + jiraLabel(b) + '.' : '')),
|
|
710
|
+
row.epicsFrom === 'record' ? note('Now from the initiative record (jira: ' + row.epics.join(', ') + '). Saving here takes over from it.') : null,
|
|
711
|
+
el('div', { class: 'ini-field' }, lbl('JIRA EPICS'), el('div', { class: 'form' }, input)),
|
|
712
|
+
E.err ? errLine(E.err) : null,
|
|
713
|
+
el('div', { class: 'chips2' }, pbtn('primary', E.busy ? 'Saving' : 'Save epics', null, { 'data-ini': 'epics-save', disabled: E.busy, onclick: save }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
|
|
714
|
+
input.focus();
|
|
715
|
+
}
|
|
716
|
+
async function save() {
|
|
717
|
+
const keys = E.text.split(/[,\s]+/).filter(Boolean);
|
|
718
|
+
if (!keys.every((k) => /^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(k))) { E.err = 'An epic key is a project key, a dash and a number, like IDPF-2685. Separate keys with a comma.'; render(); return; }
|
|
719
|
+
E.busy = true; E.err = ''; render();
|
|
720
|
+
const r = await apiFor(b.ws).post('/api/boards/ini-epics', { board: b.id, id: row.id, epics: keys });
|
|
721
|
+
E.busy = false;
|
|
722
|
+
if (!r || !r.ok) { E.err = 'The epics were not saved: ' + ((r && r.error) || 'no answer') + '.'; render(); return; }
|
|
723
|
+
sh.close();
|
|
724
|
+
toast(keys.length ? row.id + ' runs on Jira ' + keys.map((k) => k.toUpperCase()).join(', ') + '. The next Jira check reads them.' : row.id + ' has no Jira epic now.');
|
|
725
|
+
P.ini = null; dropItems(); draw();
|
|
726
|
+
}
|
|
727
|
+
render();
|
|
728
|
+
}
|
|
700
729
|
function pickTool(id) {
|
|
701
730
|
if (P.loc.tab !== 'overview' || P.loc.tool !== id) { go({ ...P.loc, tab: 'overview', tool: id }); return; }
|
|
702
731
|
const n = root.querySelector('[data-ini-tool-card="' + CSS.escape(id) + '"]');
|