domma-cms 0.91.0 → 0.92.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.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Feedback on the Domma server: every site's reports in one inbox.
3
+ *
4
+ * Chips by status (with counts), filters for site and type, search. A report
5
+ * opens in a slideover: what was sent and by whom, its status, a reply the site
6
+ * will see, internal notes it will not, and - with Waypoint Pro here - a
7
+ * Waypoint issue made from it, whose progress the report then follows.
8
+ * Right-click (or the row's kebab, or Shift+F10) for the quick moves.
9
+ */
10
+
11
+ import {api, esc, openPanel, refreshBadge, stamp, store, when} from '../lib/kit.js';
12
+
13
+ const ROW = `
14
+ <div class="fb-row" data-id="{{id}}" tabindex="0" role="button">
15
+ <span class="fb-type" data-bind-title="typeLabel"><span data-icon="{{icon}}" data-icon-size="16"></span></span>
16
+ <span class="fb-main"><strong data-bind-text="title"></strong><small data-bind-text="meta"></small></span>
17
+ <span class="fb-key" data-bind-hidden="!issueKey" data-bind-text="issueKey"></span>
18
+ <span class="fb-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
19
+ <button type="button" class="fb-kebab" aria-label="Actions" title="Actions">&#8942;</button>
20
+ </div>`;
21
+
22
+ const TONE = {new: 'info', acknowledged: 'muted', planned: 'info', 'in-progress': 'warning', done: 'success', declined: 'muted'};
23
+ const ISSUE_TYPES = [{key: 'bug', label: 'Bug'}, {key: 'story', label: 'Story'}, {key: 'task', label: 'Task'}];
24
+ const PRIORITIES = [{key: 'highest', label: 'Highest'}, {key: 'high', label: 'High'}, {key: 'medium', label: 'Medium'}, {key: 'low', label: 'Low'}, {key: 'lowest', label: 'Lowest'}];
25
+
26
+ export async function mount(root, ctx) {
27
+ const {kit, scope, here, meta, id} = ctx;
28
+ const types = meta.types || [];
29
+ const statuses = meta.statuses || [];
30
+ const iconOf = (t) => types.find(x => x.key === t)?.icon || 'message-square';
31
+ const labelOf = (t) => types.find(x => x.key === t)?.short || t;
32
+ const statusOf = (s) => statuses.find(x => x.key === s)?.label || s;
33
+
34
+ const reports = M.observable([]);
35
+ const sites = M.observable({});
36
+ const loading = M.observable(true);
37
+ const error = M.observable('');
38
+ const filter = M.observable(store.get('feedback.inbox-filter') || 'open');
39
+ const site = M.observable('');
40
+ const type = M.observable('');
41
+ const query = M.observable('');
42
+ scope(M.effect(() => store.set('feedback.inbox-filter', filter.value)));
43
+
44
+ const FILTERS = [
45
+ {key: 'open', label: 'Open', match: (s) => !['done', 'declined'].includes(s)},
46
+ ...statuses.map(s => ({key: s.key, label: s.label, match: (x) => x === s.key})),
47
+ {key: 'all', label: 'All', match: () => true}
48
+ ];
49
+ const counts = M.computed(() => {
50
+ const out = {};
51
+ for (const f of FILTERS) out[f.key] = reports.value.filter(r => f.match(r.status)).length;
52
+ return out;
53
+ });
54
+ const siteOptions = M.computed(() => Object.entries(sites.value).map(([slug, name]) => ({slug, name}))
55
+ .filter(s => reports.value.some(r => r.site === s.slug)).sort((a, b) => a.name.localeCompare(b.name)));
56
+ const rows = M.observable([]);
57
+ scope(M.effect(() => {
58
+ const f = FILTERS.find(x => x.key === filter.value) || FILTERS[0];
59
+ const q = query.value.trim().toLowerCase();
60
+ rows.value = reports.value
61
+ .filter(r => f.match(r.status))
62
+ .filter(r => !site.value || r.site === site.value)
63
+ .filter(r => !type.value || r.type === type.value)
64
+ .filter(r => !q || [r.title, r.description, r.userName, r.siteName, r.issueKey].join(' ').toLowerCase().includes(q))
65
+ .map(r => ({
66
+ id: r.id,
67
+ title: r.title,
68
+ icon: iconOf(r.type),
69
+ typeLabel: labelOf(r.type),
70
+ meta: [r.siteName, r.userName, when(r.createdAt), r.severity === 'blocker' ? 'Blocker' : ''].filter(Boolean).join(' · '),
71
+ statusLabel: statusOf(r.status),
72
+ tone: `is-${TONE[r.status] || 'muted'}`,
73
+ issueKey: r.issueKey || ''
74
+ }));
75
+ }));
76
+ const emptyText = M.computed(() => (reports.value.length ? 'Nothing matches.' : 'No feedback yet. Reports from every site the manager runs arrive here.'));
77
+
78
+ root.innerHTML = '';
79
+ const view = document.createElement('div');
80
+ view.className = 'fb-card';
81
+ view.innerHTML = `
82
+ <div class="fb-bar">
83
+ <div class="fb-seg" role="group" aria-label="Show">
84
+ ${FILTERS.map(f => `<button type="button" data-filter="${esc(f.key)}" data-bind-class="filter.value === '${esc(f.key)}' && 'is-on'">${esc(f.label)} <b data-bind-text="counts.value['${esc(f.key)}']"></b></button>`).join('')}
85
+ </div>
86
+ <span data-help-title="Feedback inbox" data-help="Everything the sites' admins sent. Open a report to set its status, reply (the site sees the reply and the status, never the internal notes) or make a Waypoint issue from it - once linked, the report follows the issue: planned, in progress, done. Right-click a row for quick moves."></span>
87
+ </div>
88
+ <div class="fb-bar">
89
+ <select class="form-select fb-filter" aria-label="Site" data-model="site.value">
90
+ <option value="">Every site</option>
91
+ </select>
92
+ <select class="form-select fb-filter" aria-label="Type" data-model="type.value">
93
+ <option value="">Every type</option>
94
+ ${types.map(t => `<option value="${esc(t.key)}">${esc(t.short)}</option>`).join('')}
95
+ </select>
96
+ <input type="search" class="form-input fb-search" placeholder="Search title, text, person, site..." aria-label="Search feedback" data-model="query.value">
97
+ </div>
98
+ <p class="fb-note" data-bind-hidden="!loading.value">Loading...</p>
99
+ <p class="fb-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
100
+ <p class="fb-note" data-bind-hidden="loading.value || error.value || rows.value.length" data-bind-text="emptyText.value"></p>
101
+ <div class="fb-list" data-each="rows.value key=id"></div>`;
102
+ root.appendChild(view);
103
+ // Help first: its scan also scans icons, and a row template scanned while its icon is still
104
+ // {{icon}} is marked processed - every row cloned from it would then stay blank.
105
+ scope(kit.helpPopovers(view, {position: 'bottom'}));
106
+ view.querySelector('.fb-list').innerHTML = ROW;
107
+ // The list re-renders whenever the rows change; its icons need scanning each time,
108
+ // after the render has landed (flush first, or the spans are scanned empty).
109
+ const listEl = view.querySelector('.fb-list');
110
+ scope(M.effect(() => {
111
+ rows.value;
112
+ Promise.resolve().then(() => { M.flush?.(); I.scan(listEl); });
113
+ }));
114
+
115
+ // The site list is small and changes only on load: fill it by hand.
116
+ const siteSelect = view.querySelector('select[aria-label="Site"]');
117
+ scope(M.effect(() => {
118
+ const chosen = site.peek();
119
+ siteSelect.length = 1;
120
+ for (const s of siteOptions.value) {
121
+ const o = document.createElement('option');
122
+ o.value = s.slug;
123
+ o.textContent = s.name;
124
+ siteSelect.appendChild(o);
125
+ }
126
+ siteSelect.value = chosen;
127
+ }));
128
+
129
+ async function load() {
130
+ try {
131
+ const res = await api.reports();
132
+ if (!here()) return;
133
+ sites.value = res.sites || {};
134
+ reports.value = Array.isArray(res.reports) ? res.reports : [];
135
+ error.value = '';
136
+ } catch (err) {
137
+ error.value = `Could not load the feedback: ${err.message || err}`;
138
+ } finally {
139
+ loading.value = false;
140
+ I.scan(view);
141
+ }
142
+ }
143
+ const replace = (r) => { reports.value = reports.peek().map(x => (x.id === r.id ? {...x, ...r} : x)); };
144
+
145
+ async function setStatus(r, status) {
146
+ try {
147
+ replace(await api.update(r.id, {status}));
148
+ refreshBadge();
149
+ E.toast(`${statusOf(status)}.`, {type: 'success'});
150
+ } catch (err) {
151
+ E.toast(err.message || String(err), {type: 'error'});
152
+ }
153
+ }
154
+
155
+ async function remove(r) {
156
+ if (!await E.confirm(`Delete "${esc(r.title)}"? The site will no longer see it either. This cannot be undone.`)) return;
157
+ try {
158
+ await api.remove(r.id);
159
+ reports.value = reports.peek().filter(x => x.id !== r.id);
160
+ refreshBadge();
161
+ E.toast('Deleted.', {type: 'success'});
162
+ } catch (err) {
163
+ E.toast(err.message || String(err), {type: 'error'});
164
+ }
165
+ }
166
+
167
+ async function open(reportId) {
168
+ let r;
169
+ try { r = await api.report(reportId); } catch (err) { E.toast(err.message || String(err), {type: 'error'}); return; }
170
+ replace(r);
171
+ let wp = {available: false, projects: []};
172
+ if (!r.issueId) { try { wp = await api.waypoint(); } catch { /* no Waypoint */ } }
173
+
174
+ const f = {
175
+ status: M.observable(r.status), reply: M.observable(r.reply || ''), notes: M.observable(r.notes || ''),
176
+ busy: M.observable(false),
177
+ project: M.observable(wp.projects?.[0]?.id || ''),
178
+ itype: M.observable(r.type === 'bug' ? 'bug' : r.type === 'feature' ? 'story' : 'task'),
179
+ priority: M.observable(r.severity === 'blocker' ? 'highest' : r.severity === 'major' ? 'high' : 'medium'),
180
+ issueKey: M.observable(r.issueKey || ''), issueStatus: M.observable(r.issueStatus || '')
181
+ };
182
+ const facts = [
183
+ ['From', `${r.userName || 'An admin'}${r.userEmail ? ` <${r.userEmail}>` : ''}`],
184
+ ['Site', r.siteName || r.site],
185
+ ['Sent', stamp(r.createdAt)],
186
+ ['Type', labelOf(r.type)],
187
+ ...(r.severity ? [['Severity', r.severity]] : []),
188
+ ...(r.where ? [['Where', r.where]] : []),
189
+ ...(r.cmsVersion ? [['CMS', r.cmsVersion]] : [])
190
+ ];
191
+ let panel = null;
192
+ panel = await openPanel({
193
+ title: r.title,
194
+ size: 'lg',
195
+ model: f,
196
+ html: `<div class="fb-detail">
197
+ <dl class="fb-facts">${facts.map(([a, b]) => `<div><dt>${esc(a)}</dt><dd>${esc(b)}</dd></div>`).join('')}</dl>
198
+ <h4>What they sent</h4>
199
+ <div class="fb-text">${esc(r.description)}</div>
200
+
201
+ <div class="fb-form">
202
+ <label class="form-label" for="fb-status">Status <span data-help="What the site sees. Once a Waypoint issue is linked, the status follows it - except Not planned, which stays until you change it."></span></label>
203
+ <select id="fb-status" class="form-select" data-model="status.value">
204
+ ${statuses.map(s => `<option value="${esc(s.key)}">${esc(s.label)}</option>`).join('')}
205
+ </select>
206
+ <label class="form-label" for="fb-reply">Reply to the site <span data-help="The site's admins see this in their Feedback screen, and are told there is a reply."></span></label>
207
+ <textarea id="fb-reply" class="form-textarea" rows="5" maxlength="8000" data-model="reply.value"></textarea>
208
+ <label class="form-label" for="fb-notes">Internal notes <span data-help="Only here. Never sent to the site."></span></label>
209
+ <textarea id="fb-notes" class="form-textarea" rows="3" maxlength="8000" data-model="notes.value"></textarea>
210
+ <div class="fb-actions"><span class="fb-spacer"></span>
211
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="busy.value"><span data-icon="check" data-icon-size="13"></span> Save</button>
212
+ </div>
213
+ </div>
214
+
215
+ <div class="fb-waypoint" data-bind-hidden="!issueKey.value">
216
+ <h4>Waypoint</h4>
217
+ <p>Tracked as <a href="#/plugins/waypoint-pro" class="fb-issue-link" data-on-click="openIssue" title="Open the issue in Waypoint"><strong data-bind-text="issueKey.value"></strong></a><span data-bind-hidden="!issueStatus.value"> - <span data-bind-text="issueStatus.value"></span></span>.</p>
218
+ </div>
219
+ ${!r.issueId && wp.available ? `<div class="fb-waypoint" data-bind-hidden="issueKey.value">
220
+ <h4>Make a Waypoint issue <span data-help="Files this report in Waypoint Pro with its text, the site and who sent it. The report then follows the issue's progress."></span></h4>
221
+ ${wp.projects?.length ? `<div class="fb-grid3">
222
+ <label><span class="form-label">Project</span><select class="form-select" data-model="project.value">${wp.projects.map(p => `<option value="${esc(p.id)}">${esc(p.key ? `${p.key} - ${p.name}` : p.name)}</option>`).join('')}</select></label>
223
+ <label><span class="form-label">Type</span><select class="form-select" data-model="itype.value">${ISSUE_TYPES.map(t => `<option value="${t.key}">${t.label}</option>`).join('')}</select></label>
224
+ <label><span class="form-label">Priority</span><select class="form-select" data-model="priority.value">${PRIORITIES.map(p => `<option value="${p.key}">${p.label}</option>`).join('')}</select></label>
225
+ </div>
226
+ <div class="fb-actions"><span class="fb-spacer"></span>
227
+ <button type="button" class="btn btn-sm btn-outline" data-on-click="promote" data-bind-disabled="busy.value"><span data-icon="layers" data-icon-size="13"></span> Create Waypoint issue</button>
228
+ </div>` : '<p class="fb-note">Waypoint has no projects yet. Make one there first.</p>'}
229
+ </div>` : ''}
230
+
231
+ <h4>History</h4>
232
+ <ul class="fb-history">${(r.history || []).slice().reverse().map(h => `<li><span>${esc(h.what)}</span><small>${esc(h.by || '')} · ${esc(stamp(h.at))}</small></li>`).join('')}</ul>
233
+ </div>`,
234
+ methods: {
235
+ async save() {
236
+ if (f.busy.peek()) return;
237
+ f.busy.value = true;
238
+ try {
239
+ const next = await api.update(r.id, {status: f.status.peek(), reply: f.reply.peek(), notes: f.notes.peek()});
240
+ replace(next);
241
+ refreshBadge();
242
+ E.toast('Saved.', {type: 'success'});
243
+ panel?.close();
244
+ } catch (err) {
245
+ E.toast(err.message || String(err), {type: 'error'});
246
+ } finally {
247
+ f.busy.value = false;
248
+ }
249
+ },
250
+ openIssue(e) {
251
+ e?.preventDefault?.();
252
+ const key = f.issueKey.peek();
253
+ if (!key) return;
254
+ panel?.close();
255
+ location.hash = `#/plugins/waypoint-pro/i/${encodeURIComponent(key)}`;
256
+ },
257
+ async promote() {
258
+ if (f.busy.peek() || !f.project.peek()) return;
259
+ f.busy.value = true;
260
+ try {
261
+ const next = await api.promote(r.id, {projectId: f.project.peek(), type: f.itype.peek(), priority: f.priority.peek()});
262
+ replace(next);
263
+ f.issueKey.value = next.issueKey || '';
264
+ f.status.value = next.status;
265
+ refreshBadge();
266
+ E.toast(`Filed as ${next.issueKey || 'a Waypoint issue'}.`, {type: 'success'});
267
+ } catch (err) {
268
+ E.toast(err.message || String(err), {type: 'error'});
269
+ } finally {
270
+ f.busy.value = false;
271
+ }
272
+ }
273
+ }
274
+ });
275
+ // Opening a new report is seeing it.
276
+ if (r.status === 'new') {
277
+ try { const next = await api.update(r.id, {status: 'acknowledged'}); replace(next); f.status.value = next.status; refreshBadge(); }
278
+ catch { /* stays new */ }
279
+ }
280
+ }
281
+
282
+ const bindings = M.applyBindings({loading, error, filter, site, type, query, rows, counts, emptyText}, view);
283
+ scope(() => bindings.dispose());
284
+ view.querySelector('.fb-seg').addEventListener('click', (e) => {
285
+ const b = e.target.closest('[data-filter]');
286
+ if (b) filter.value = b.dataset.filter;
287
+ });
288
+
289
+ // Rows: click or Enter opens; right-click, the kebab or Shift+F10 = the menu.
290
+ const list = view.querySelector('.fb-list');
291
+ const byId = (rid) => reports.peek().find(x => x.id === rid);
292
+ const menu = E.contextMenu(list, {
293
+ cursor: 'glyph', match: '.fb-row', inherit: false, density: 'compact',
294
+ items: (row) => {
295
+ const r = byId(row.dataset.id);
296
+ if (!r) return [];
297
+ return [
298
+ {label: 'Open', icon: 'eye', action: () => open(r.id)},
299
+ {type: 'divider'},
300
+ ...statuses.filter(s => s.key !== r.status).map(s => ({label: `Mark ${s.label.toLowerCase()}`, icon: 'flag', action: () => setStatus(r, s.key)})),
301
+ {type: 'divider'},
302
+ {label: 'Delete', icon: 'trash', danger: true, action: () => remove(r)}
303
+ ];
304
+ }
305
+ });
306
+ const onClick = (e) => {
307
+ const row = e.target.closest('.fb-row');
308
+ if (!row) return;
309
+ const kebab = e.target.closest('.fb-kebab');
310
+ if (kebab) { kit.openMenuAt(kebab); return; }
311
+ open(row.dataset.id);
312
+ };
313
+ const onKey = (e) => {
314
+ const row = e.target.closest('.fb-row');
315
+ if (!row) return;
316
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); kit.openMenuAt(row); }
317
+ else if (e.key === 'Enter' && e.target === row) open(row.dataset.id);
318
+ };
319
+ list.addEventListener('click', onClick);
320
+ list.addEventListener('keydown', onKey);
321
+ scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); menu?.destroy?.(); });
322
+
323
+ await load();
324
+ if (id && here()) open(id);
325
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Feedback on a site: send Domma a bug, a request, a question or a thank you,
3
+ * and see what became of everything this site sent.
4
+ *
5
+ * A report is kept on this site before it is sent, so a Domma server that is
6
+ * restarting only delays it ("Waiting to send"); opening this screen tries
7
+ * again. A site the manager did not start has nowhere to send to, and says so.
8
+ */
9
+
10
+ import {api, esc, openPanel, refreshBadge, stamp, when} from '../lib/kit.js';
11
+
12
+ const ROW = `
13
+ <div class="fb-row" data-id="{{key}}" tabindex="0" role="button">
14
+ <span class="fb-type" data-bind-title="typeLabel"><span data-icon="{{icon}}" data-icon-size="16"></span></span>
15
+ <span class="fb-main"><strong data-bind-text="title"></strong><small data-bind-text="meta"></small></span>
16
+ <span class="fb-dot" data-bind-hidden="!unread" title="New reply from Domma"></span>
17
+ <span class="fb-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
18
+ </div>`;
19
+
20
+ const TONE = {new: 'info', acknowledged: 'muted', planned: 'info', 'in-progress': 'warning', done: 'success', declined: 'muted',
21
+ waiting: 'warning', refused: 'bad'};
22
+
23
+ export async function mount(root, ctx) {
24
+ const {kit, scope, here, meta, id} = ctx;
25
+ const types = meta.types || [];
26
+ const iconOf = (t) => types.find(x => x.key === t)?.icon || 'message-square';
27
+ const labelOf = (t) => types.find(x => x.key === t)?.short || t;
28
+
29
+ const connected = M.observable(Boolean(ctx.mode.connected));
30
+ const reports = M.observable([]);
31
+ const loading = M.observable(true);
32
+ const reachable = M.observable(true);
33
+ const error = M.observable('');
34
+ const rows = M.observable([]);
35
+ scope(M.effect(() => {
36
+ rows.value = reports.value.map(r => ({
37
+ key: r.id || r.ref,
38
+ title: r.title,
39
+ icon: iconOf(r.type),
40
+ typeLabel: labelOf(r.type),
41
+ meta: [when(r.createdAt), r.userName, r.issueKey ? `Tracked as ${r.issueKey}` : ''].filter(Boolean).join(' · '),
42
+ statusLabel: r.statusLabel,
43
+ tone: `is-${TONE[r.status] || 'muted'}`,
44
+ unread: Boolean(r.unread)
45
+ }));
46
+ }));
47
+ const waitingCount = M.computed(() => reports.value.filter(r => r.status === 'waiting').length);
48
+
49
+ root.innerHTML = '';
50
+ const view = document.createElement('div');
51
+ view.className = 'fb-card';
52
+ view.innerHTML = `
53
+ <div class="fb-empty-state" data-bind-hidden="connected.value">
54
+ <span data-icon="cloud-off" data-icon-size="28"></span>
55
+ <h3>This site is not connected to Domma</h3>
56
+ <p>Feedback travels through the Domma server that runs this site. This one was not started by it, so there is nowhere to send from here. If you think that is wrong, ask whoever set the site up.</p>
57
+ </div>
58
+ <div data-bind-hidden="!connected.value">
59
+ <div class="fb-bar">
60
+ <div class="fb-intro">
61
+ <strong>Tell Domma what is broken, missing or good.</strong>
62
+ <span>Every report is read. You will see its progress here, and any reply.</span>
63
+ </div>
64
+ <span data-help-title="Feedback" data-help="Reports go to the Domma team, with your name and this site attached so we can come back to you. You see everything this site has sent, whoever sent it. A report shows as Seen, Planned, In progress, Done or Not planned as it moves, and a reply from Domma appears in it - the sidebar badge counts replies you have not opened."></span>
65
+ <span class="fb-spacer"></span>
66
+ <button type="button" class="btn btn-primary btn-sm" data-on-click="compose"><span data-icon="send" data-icon-size="14"></span> Send feedback</button>
67
+ </div>
68
+ <p class="fb-note is-warn" data-bind-hidden="!waitingCount.value">
69
+ <span data-icon="clock" data-icon-size="14"></span>
70
+ <span data-bind-text="waitingCount.value === 1 ? '1 report is waiting to send' : waitingCount.value + ' reports are waiting to send'"></span> - it goes as soon as the Domma server answers.
71
+ <button type="button" class="btn btn-ghost btn-xs" data-on-click="reload">Try now</button>
72
+ </p>
73
+ <p class="fb-note is-warn" data-bind-hidden="reachable.value || loading.value">Domma could not be reached just now, so the statuses below may be out of date.</p>
74
+ <p class="fb-note" data-bind-hidden="!loading.value">Loading...</p>
75
+ <p class="fb-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
76
+ <p class="fb-note" data-bind-hidden="loading.value || rows.value.length">Nothing sent yet. Found something odd, or wish the CMS did something it does not? Send feedback.</p>
77
+ <div class="fb-list" data-each="rows.value key=key"></div>
78
+ </div>`;
79
+ root.appendChild(view);
80
+ // Help first: its scan also scans icons, and a row template scanned while its icon is still
81
+ // {{icon}} is marked processed - every row cloned from it would then stay blank.
82
+ scope(kit.helpPopovers(view, {position: 'bottom'}));
83
+ view.querySelector('.fb-list').innerHTML = ROW;
84
+ // The list re-renders whenever the rows change; its icons need scanning each time,
85
+ // after the render has landed (flush first, or the spans are scanned empty).
86
+ const listEl = view.querySelector('.fb-list');
87
+ scope(M.effect(() => {
88
+ rows.value;
89
+ Promise.resolve().then(() => { M.flush?.(); I.scan(listEl); });
90
+ }));
91
+
92
+ async function load() {
93
+ loading.value = true;
94
+ try {
95
+ const res = await api.sent();
96
+ if (!here()) return;
97
+ connected.value = Boolean(res.connected);
98
+ reachable.value = Boolean(res.reachable) || !res.connected;
99
+ reports.value = Array.isArray(res.reports) ? res.reports : [];
100
+ error.value = '';
101
+ } catch (err) {
102
+ error.value = `Could not load your feedback: ${err.message || err}`;
103
+ } finally {
104
+ loading.value = false;
105
+ I.scan(view);
106
+ }
107
+ }
108
+
109
+ const byKey = (k) => reports.peek().find(r => (r.id || r.ref) === k);
110
+
111
+ function compose() {
112
+ const f = {
113
+ type: M.observable('bug'), title: M.observable(''), description: M.observable(''),
114
+ where: M.observable(''), severity: M.observable('minor'), busy: M.observable(false)
115
+ };
116
+ const isBug = M.computed(() => f.type.value === 'bug');
117
+ let panel = null;
118
+ openPanel({
119
+ title: 'Send feedback to Domma',
120
+ size: 'md',
121
+ model: {...f, isBug},
122
+ html: `<div class="fb-form">
123
+ <label class="form-label">What is it?</label>
124
+ <div class="fb-types" role="radiogroup" aria-label="Type">
125
+ ${types.map(t => `<button type="button" role="radio" data-type="${esc(t.key)}" data-bind-class="type.value === '${esc(t.key)}' && 'is-on'" data-bind-aria-checked="type.value === '${esc(t.key)}'"><span data-icon="${esc(t.icon)}" data-icon-size="16"></span>${esc(t.label)}</button>`).join('')}
126
+ </div>
127
+ <label class="form-label" for="fb-title">Title</label>
128
+ <input id="fb-title" class="form-input" maxlength="140" data-model="title.value" placeholder="One line: what happened, or what you would like">
129
+ <label class="form-label" for="fb-desc">Description <span data-help="For a bug: what you did, what you expected, and what happened instead. For a request: what you are trying to get done - the why helps more than the how."></span></label>
130
+ <textarea id="fb-desc" class="form-textarea" rows="8" maxlength="8000" data-model="description.value"></textarea>
131
+ <div data-bind-hidden="!isBug.value">
132
+ <label class="form-label" for="fb-sev">How bad is it?</label>
133
+ <select id="fb-sev" class="form-select" data-model="severity.value">
134
+ ${(ctx.meta.severities || [{key: 'minor', label: 'Minor - a nuisance'}, {key: 'major', label: 'Major - gets in the way'}, {key: 'blocker', label: 'Blocker - cannot carry on'}]).map(s => `<option value="${esc(s.key)}">${esc(s.label)}</option>`).join('')}
135
+ </select>
136
+ </div>
137
+ <label class="form-label" for="fb-where">Where <span data-help="The screen or page it is about, if there is one - e.g. Pages, the Blog editor, /contact. Optional."></span></label>
138
+ <input id="fb-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
139
+ <p class="fb-hint">Sent with your name, this site and its CMS version, so Domma can reply.</p>
140
+ <div class="fb-actions"><span class="fb-spacer"></span>
141
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
142
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
143
+ </div>
144
+ </div>`,
145
+ methods: {
146
+ cancel: () => panel?.close(),
147
+ async send() {
148
+ if (f.busy.peek()) return;
149
+ f.busy.value = true;
150
+ try {
151
+ const r = await api.send({type: f.type.peek(), title: f.title.peek(), description: f.description.peek(),
152
+ where: f.where.peek(), severity: f.type.peek() === 'bug' ? f.severity.peek() : ''});
153
+ E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
154
+ panel?.close();
155
+ await load();
156
+ } catch (err) {
157
+ E.toast(err.message || String(err), {type: 'error'});
158
+ } finally {
159
+ f.busy.value = false;
160
+ }
161
+ }
162
+ }
163
+ }).then(p => {
164
+ panel = p;
165
+ p.panel.querySelector('.fb-types').addEventListener('click', (e) => {
166
+ const b = e.target.closest('[data-type]');
167
+ if (b) f.type.value = b.dataset.type;
168
+ });
169
+ p.panel.querySelector('#fb-title')?.focus();
170
+ });
171
+ }
172
+
173
+ async function open(k) {
174
+ const r = byKey(k);
175
+ if (!r) return;
176
+ const lines = [
177
+ ['Type', labelOf(r.type)],
178
+ ['Status', r.statusLabel],
179
+ ['Sent', `${stamp(r.createdAt)}${r.userName ? ` by ${r.userName}` : ''}`],
180
+ ...(r.severity ? [['Severity', r.severity]] : []),
181
+ ...(r.where ? [['Where', r.where]] : []),
182
+ ...(r.issueKey ? [['Tracked as', `${r.issueKey}${r.issueStatus ? ` (${r.issueStatus})` : ''}`]] : []),
183
+ ...(r.lastError ? [['Last try', r.lastError]] : [])
184
+ ];
185
+ await openPanel({
186
+ title: r.title,
187
+ size: 'md',
188
+ html: `<div class="fb-detail">
189
+ <dl class="fb-facts">${lines.map(([a, b]) => `<div><dt>${esc(a)}</dt><dd>${esc(b)}</dd></div>`).join('')}</dl>
190
+ <h4>What was sent</h4>
191
+ <div class="fb-text">${esc(r.description)}</div>
192
+ ${r.reply ? `<h4><span data-icon="corner-up-left" data-icon-size="14"></span> Domma replied <small>${esc(stamp(r.replyAt))}</small></h4><div class="fb-text fb-reply">${esc(r.reply)}</div>` : '<p class="fb-note">No reply yet. The status above moves as Domma works on it.</p>'}
193
+ </div>`
194
+ });
195
+ if (r.unread && r.id) {
196
+ try {
197
+ await api.seen(r.id, r.replyAt);
198
+ reports.value = reports.peek().map(x => (x === r ? {...x, unread: false} : x));
199
+ refreshBadge();
200
+ } catch { /* the badge catches up next time */ }
201
+ }
202
+ }
203
+
204
+ const bindings = M.applyBindings({connected, loading, reachable, error, rows, waitingCount}, view, {
205
+ methods: {compose, reload: () => load()}
206
+ });
207
+ scope(() => bindings.dispose());
208
+
209
+ const list = view.querySelector('.fb-list');
210
+ const onClick = (e) => { const row = e.target.closest('.fb-row'); if (row) open(row.dataset.id); };
211
+ const onKey = (e) => { const row = e.target.closest('.fb-row'); if (row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); open(row.dataset.id); } };
212
+ list.addEventListener('click', onClick);
213
+ list.addEventListener('keydown', onKey);
214
+ scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); });
215
+
216
+ I.scan(view);
217
+ if (connected.peek()) {
218
+ await load();
219
+ if (id && here()) open(id);
220
+ } else {
221
+ loading.value = false;
222
+ }
223
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "slug": "feedback-reports",
3
+ "title": "Feedback Reports",
4
+ "description": "Feedback sent to Domma by the admins of every site the manager runs. Read and answer it in Feedback, never here: the sites see only what the Feedback inbox lets them.",
5
+ "plugin": "feedback",
6
+ "fields": [
7
+ {"name": "site", "label": "Site", "type": "text"},
8
+ {"name": "type", "label": "Type", "type": "text"},
9
+ {"name": "title", "label": "Title", "type": "text"},
10
+ {"name": "description", "label": "Description", "type": "textarea"},
11
+ {"name": "status", "label": "Status", "type": "text"},
12
+ {"name": "userName", "label": "Sent by", "type": "text"},
13
+ {"name": "userEmail", "label": "Email", "type": "text"},
14
+ {"name": "reply", "label": "Reply to the site", "type": "textarea"},
15
+ {"name": "notes", "label": "Internal notes", "type": "textarea"},
16
+ {"name": "issueKey", "label": "Waypoint issue", "type": "text"}
17
+ ],
18
+ "api": {
19
+ "create": {"enabled": false, "access": "admin"},
20
+ "read": {"enabled": false, "access": "admin"},
21
+ "update": {"enabled": false, "access": "admin"},
22
+ "delete": {"enabled": false, "access": "admin"}
23
+ }
24
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Feedback - default settings (config/plugins.json `feedback.settings` overrides).
3
+ *
4
+ * Sender side:
5
+ * syncMinutes how often a site sends what is waiting and looks for replies
6
+ *
7
+ * Receiver side (the manager):
8
+ * perSiteMax reports one site may send in perSiteMinutes; more answer 429
9
+ * perSiteMinutes the window for perSiteMax
10
+ */
11
+ export default {
12
+ syncMinutes: 60,
13
+ perSiteMax: 10,
14
+ perSiteMinutes: 10
15
+ };