@bongos/core 1.21.59 → 1.21.61
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/.bongos-core.json +59 -34
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +19 -0
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/api/openapi.json +349 -3
- package/docs/api-reference.md +12 -2
- package/docs/copy-inventory.md +20 -8
- package/docs/copy-registry.json +117 -9
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +392 -380
- package/modules/builder-settings/filter-views.js +136 -0
- package/modules/builder-settings/routes/settings.js +90 -0
- package/modules/hall-ui/public/goals-page.js +8 -2
- package/modules/hall-ui/public/hall-kit.js +128 -2
- package/modules/hall-ui/public/project-settings.css +5 -0
- package/modules/hall-ui/public/project-settings.html +12 -0
- package/modules/hall-ui/public/project-settings.js +20 -0
- package/modules/hall-ui/public/style.css +32 -0
- package/modules/hall-ui/public/work.js +11 -3
- package/modules/hall-ui/records/filter-views.md +11 -0
- package/modules/hall-ui/records/project-danger-zone.md +10 -0
- package/modules/public-landing/public/projects.html +30 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/routes/core-update.js +33 -0
- package/src/module-api.js +1 -1
- package/tests/filter_views.mjs +144 -0
- package/tests/focus_version_filter_default.mjs +3 -2
- package/tests/project_manage_link.mjs +111 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/builder-settings/filter-views.js — a builder's SAVED FILTER VIEWS
|
|
4
|
+
// (task 1004553). A view is a named set of filter values for one hall page
|
|
5
|
+
// (Goals, Tasks, …) that the builder can come back to, on any device.
|
|
6
|
+
//
|
|
7
|
+
// PERSONAL BY CONSTRUCTION (owner decision, 2026-10-02). Views live on the
|
|
8
|
+
// builder's own row, so there is no "whose view is this" check to get wrong: the
|
|
9
|
+
// routes only ever read and write req.builder.id's prefs, and another builder's
|
|
10
|
+
// views are simply not reachable. The project-wide default is the Roadmap's
|
|
11
|
+
// focus version (task 1004551), not a shared view.
|
|
12
|
+
//
|
|
13
|
+
// Storage: one reserved key ('filter_views') inside the `behavior_prefs` jsonb on
|
|
14
|
+
// `builders` (migration 119 — the blob the wandering/render/interaction knobs
|
|
15
|
+
// share), so this adds NO migration. Shape:
|
|
16
|
+
//
|
|
17
|
+
// { "<page>": [ { id, name, values: { "<control id>": "<value>" }, is_default } ] }
|
|
18
|
+
//
|
|
19
|
+
// Every helper below is pure: it takes the stored prefs and returns the next
|
|
20
|
+
// prefs (siblings untouched), or { ok:false, errors } — the route does the I/O.
|
|
21
|
+
//
|
|
22
|
+
// Opening order on a page (enforced by the hall filter bar, not here):
|
|
23
|
+
// URL filter > my default view > project focus version > any.
|
|
24
|
+
|
|
25
|
+
const RESERVED_KEY = 'filter_views';
|
|
26
|
+
|
|
27
|
+
// Bounds that keep one builder's blob small. A page key is the page's own short
|
|
28
|
+
// slug ('goals', 'work'); a value is whatever the filter control holds.
|
|
29
|
+
const PAGE_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
30
|
+
const CONTROL_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
31
|
+
const MAX_NAME = 60;
|
|
32
|
+
const MAX_VIEWS_PER_PAGE = 20;
|
|
33
|
+
const MAX_VALUES = 30;
|
|
34
|
+
const MAX_VALUE_LEN = 200;
|
|
35
|
+
|
|
36
|
+
function pageViews(prefs, page) {
|
|
37
|
+
const all = prefs && prefs[RESERVED_KEY];
|
|
38
|
+
const list = all && typeof all === 'object' && !Array.isArray(all) ? all[page] : null;
|
|
39
|
+
return Array.isArray(list) ? list : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withPage(prefs, page, list) {
|
|
43
|
+
const next = { ...(prefs || {}) };
|
|
44
|
+
const all = { ...((next[RESERVED_KEY] && typeof next[RESERVED_KEY] === 'object') ? next[RESERVED_KEY] : {}) };
|
|
45
|
+
if (list.length) all[page] = list; else delete all[page];
|
|
46
|
+
if (Object.keys(all).length) next[RESERVED_KEY] = all; else delete next[RESERVED_KEY];
|
|
47
|
+
return next;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function checkPage(page) {
|
|
51
|
+
return typeof page === 'string' && PAGE_RE.test(page) ? null : 'page must be a short lowercase page key, e.g. "goals"';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cleanName(name) {
|
|
55
|
+
if (typeof name !== 'string') return { error: 'name is required' };
|
|
56
|
+
const n = name.trim().replace(/\s+/g, ' ');
|
|
57
|
+
if (!n) return { error: 'name is required' };
|
|
58
|
+
if (n.length > MAX_NAME) return { error: `name must be ${MAX_NAME} characters or fewer` };
|
|
59
|
+
return { name: n };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cleanValues(values) {
|
|
63
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) return { error: 'values must be an object of filter id → value' };
|
|
64
|
+
const entries = Object.entries(values);
|
|
65
|
+
if (entries.length > MAX_VALUES) return { error: `a view holds at most ${MAX_VALUES} filters` };
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const [k, v] of entries) {
|
|
68
|
+
if (!CONTROL_RE.test(k)) return { error: `"${String(k).slice(0, 40)}" is not a filter id` };
|
|
69
|
+
if (typeof v !== 'string') return { error: `filter "${k}" must be a string` };
|
|
70
|
+
if (v.length > MAX_VALUE_LEN) return { error: `filter "${k}" is too long` };
|
|
71
|
+
out[k] = v;
|
|
72
|
+
}
|
|
73
|
+
return { values: out };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const fail = (...errors) => ({ ok: false, errors });
|
|
77
|
+
|
|
78
|
+
// The views for one page, as the API returns them.
|
|
79
|
+
function listViews(prefs, page) {
|
|
80
|
+
return pageViews(prefs, page).map((v) => ({ id: v.id, name: v.name, values: { ...v.values }, is_default: v.is_default === true }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Save the current filters under a name. Saving under a name that already exists
|
|
84
|
+
// on this page REPLACES that view's filters (the natural "update my view"), so
|
|
85
|
+
// names stay unique per page. is_default:true makes it the only default.
|
|
86
|
+
function saveView(prefs, page, body = {}) {
|
|
87
|
+
const pe = checkPage(page); if (pe) return fail(pe);
|
|
88
|
+
const n = cleanName(body.name); if (n.error) return fail(n.error);
|
|
89
|
+
const vals = cleanValues(body.values); if (vals.error) return fail(vals.error);
|
|
90
|
+
const makeDefault = body.is_default === true;
|
|
91
|
+
let list = pageViews(prefs, page).map((v) => ({ ...v }));
|
|
92
|
+
let view = list.find((v) => v.name.toLowerCase() === n.name.toLowerCase());
|
|
93
|
+
if (view) {
|
|
94
|
+
view.name = n.name;
|
|
95
|
+
view.values = vals.values;
|
|
96
|
+
if (makeDefault) view.is_default = true;
|
|
97
|
+
} else {
|
|
98
|
+
if (list.length >= MAX_VIEWS_PER_PAGE) return fail(`you can keep at most ${MAX_VIEWS_PER_PAGE} views on one page — delete one first`);
|
|
99
|
+
const id = list.reduce((m, v) => Math.max(m, Number(v.id) || 0), 0) + 1;
|
|
100
|
+
view = { id, name: n.name, values: vals.values, is_default: makeDefault };
|
|
101
|
+
list.push(view);
|
|
102
|
+
}
|
|
103
|
+
if (makeDefault) list = list.map((v) => ({ ...v, is_default: v.id === view.id }));
|
|
104
|
+
return { ok: true, prefs: withPage(prefs, page, list), view: listViews(withPage(prefs, page, list), page).find((v) => v.id === view.id) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Rename and/or set or clear the default flag on one of my views.
|
|
108
|
+
function updateView(prefs, page, id, body = {}) {
|
|
109
|
+
const pe = checkPage(page); if (pe) return fail(pe);
|
|
110
|
+
let list = pageViews(prefs, page).map((v) => ({ ...v }));
|
|
111
|
+
const view = list.find((v) => v.id === Number(id));
|
|
112
|
+
if (!view) return { ok: false, notFound: true, errors: ['no such view'] };
|
|
113
|
+
if (body.name !== undefined) {
|
|
114
|
+
const n = cleanName(body.name); if (n.error) return fail(n.error);
|
|
115
|
+
if (list.some((v) => v.id !== view.id && v.name.toLowerCase() === n.name.toLowerCase())) return fail(`you already have a view called "${n.name}" here`);
|
|
116
|
+
view.name = n.name;
|
|
117
|
+
}
|
|
118
|
+
if (body.is_default !== undefined) {
|
|
119
|
+
if (typeof body.is_default !== 'boolean') return fail('is_default must be true or false');
|
|
120
|
+
list = list.map((v) => ({ ...v, is_default: body.is_default ? v.id === view.id : (v.id === view.id ? false : v.is_default === true) }));
|
|
121
|
+
}
|
|
122
|
+
const next = withPage(prefs, page, list);
|
|
123
|
+
return { ok: true, prefs: next, view: listViews(next, page).find((v) => v.id === view.id) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function deleteView(prefs, page, id) {
|
|
127
|
+
const pe = checkPage(page); if (pe) return fail(pe);
|
|
128
|
+
const list = pageViews(prefs, page);
|
|
129
|
+
if (!list.some((v) => v.id === Number(id))) return { ok: false, notFound: true, errors: ['no such view'] };
|
|
130
|
+
return { ok: true, prefs: withPage(prefs, page, list.filter((v) => v.id !== Number(id))) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
RESERVED_KEY, MAX_VIEWS_PER_PAGE, checkPage,
|
|
135
|
+
listViews, saveView, updateView, deleteView,
|
|
136
|
+
};
|
|
@@ -30,6 +30,7 @@ const modelAllocPrefs = require('../model-alloc-prefs');
|
|
|
30
30
|
const renderPrefs = require('../render-prefs');
|
|
31
31
|
const profileNudgePrefs = require('../profile-nudge-prefs');
|
|
32
32
|
const displayName = require('../display-name');
|
|
33
|
+
const filterViews = require('../filter-views');
|
|
33
34
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
34
35
|
const log = api.logger('builder-settings');
|
|
35
36
|
|
|
@@ -434,6 +435,95 @@ module.exports = function buildBuilderSettingsRouter() {
|
|
|
434
435
|
}
|
|
435
436
|
});
|
|
436
437
|
|
|
438
|
+
// Per-builder SAVED FILTER VIEWS (task 1004553; shares the behavior_prefs jsonb /
|
|
439
|
+
// migration 119). A view is a named set of filter values for one hall page. They
|
|
440
|
+
// are PERSONAL: every route reads and writes only req.builder.id's own prefs, so
|
|
441
|
+
// another builder's views are unreachable — not hidden, absent. The page is a
|
|
442
|
+
// short key ('goals', 'work'): a query param on GET/DELETE, a body field on
|
|
443
|
+
// POST/PATCH. Shapes + bounds live in filter-views.js.
|
|
444
|
+
//
|
|
445
|
+
// rank: any-builder — own filter config; same posture as /me/render.
|
|
446
|
+
router.get('/me/filter-views', api.requireBuilder, async (req, res) => {
|
|
447
|
+
try {
|
|
448
|
+
const page = req.query && req.query.page;
|
|
449
|
+
const pe = filterViews.checkPage(page);
|
|
450
|
+
if (pe) return res.fail('invalid_page', { status: 400, message: pe });
|
|
451
|
+
const stored = await db.getBuilderBehaviorPrefs(req.builder.id);
|
|
452
|
+
res.json({ page, views: filterViews.listViews(stored, page) });
|
|
453
|
+
} catch (err) {
|
|
454
|
+
log.error('GET /me/filter-views', err);
|
|
455
|
+
res.fail('read_failed', { status: 500, message: 'internal error' });
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Save the current filters as a view. Body: { page, name, values, is_default? }.
|
|
460
|
+
// An existing name on the same page is overwritten (that is "update my view").
|
|
461
|
+
// rank: any-builder — own filter config.
|
|
462
|
+
router.post('/me/filter-views', api.requireBuilder, async (req, res) => {
|
|
463
|
+
// Field shapes here; the per-value bounds (page key, name length, filter ids)
|
|
464
|
+
// are filter-views.js's, so the refusal names the actual problem.
|
|
465
|
+
if (api.validateOrRespond(req, res, {
|
|
466
|
+
page: { required: true, type: 'string', maxLength: 40 },
|
|
467
|
+
name: { required: true, type: 'string', maxLength: 200 },
|
|
468
|
+
values: { required: true, type: 'object' },
|
|
469
|
+
is_default: { type: 'boolean' },
|
|
470
|
+
}, { strict: true })) return undefined;
|
|
471
|
+
try {
|
|
472
|
+
const body = req.body || {};
|
|
473
|
+
const existing = await db.getBuilderBehaviorPrefs(req.builder.id);
|
|
474
|
+
const r = filterViews.saveView(existing, body.page, body);
|
|
475
|
+
if (!r.ok) return res.fail('invalid_view', { status: 422, message: r.errors[0], details: { errors: r.errors } });
|
|
476
|
+
const saved = await db.setBuilderBehaviorPrefs(req.builder.id, r.prefs);
|
|
477
|
+
res.status(201).json({ page: body.page, view: r.view, views: filterViews.listViews(saved, body.page) });
|
|
478
|
+
} catch (err) {
|
|
479
|
+
log.error('POST /me/filter-views', err);
|
|
480
|
+
res.fail('write_failed', { status: 500, message: 'internal error' });
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Rename a view and/or make it (or stop it being) my default for that page.
|
|
485
|
+
// Body: { page, name?, is_default? }.
|
|
486
|
+
// rank: any-builder — own filter config.
|
|
487
|
+
router.patch('/me/filter-views/:id', api.requireBuilder, async (req, res) => {
|
|
488
|
+
if (api.validateOrRespond(req, res, {
|
|
489
|
+
page: { required: true, type: 'string', maxLength: 40 },
|
|
490
|
+
name: { type: 'string', maxLength: 200 },
|
|
491
|
+
is_default: { type: 'boolean' },
|
|
492
|
+
}, { strict: true })) return undefined;
|
|
493
|
+
try {
|
|
494
|
+
const body = req.body || {};
|
|
495
|
+
const existing = await db.getBuilderBehaviorPrefs(req.builder.id);
|
|
496
|
+
const r = filterViews.updateView(existing, body.page, req.params.id, body);
|
|
497
|
+
if (!r.ok) {
|
|
498
|
+
if (r.notFound) return res.fail('view_not_found', { status: 404, message: 'That view no longer exists. Reload and try again.' });
|
|
499
|
+
return res.fail('invalid_view', { status: 422, message: r.errors[0], details: { errors: r.errors } });
|
|
500
|
+
}
|
|
501
|
+
const saved = await db.setBuilderBehaviorPrefs(req.builder.id, r.prefs);
|
|
502
|
+
res.json({ page: body.page, view: r.view, views: filterViews.listViews(saved, body.page) });
|
|
503
|
+
} catch (err) {
|
|
504
|
+
log.error('PATCH /me/filter-views/:id', err);
|
|
505
|
+
res.fail('write_failed', { status: 500, message: 'internal error' });
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// rank: any-builder — own filter config. ?page=<key> names the page.
|
|
510
|
+
router.delete('/me/filter-views/:id', api.requireBuilder, async (req, res) => {
|
|
511
|
+
try {
|
|
512
|
+
const page = req.query && req.query.page;
|
|
513
|
+
const existing = await db.getBuilderBehaviorPrefs(req.builder.id);
|
|
514
|
+
const r = filterViews.deleteView(existing, page, req.params.id);
|
|
515
|
+
if (!r.ok) {
|
|
516
|
+
if (r.notFound) return res.fail('view_not_found', { status: 404, message: 'That view no longer exists. Reload and try again.' });
|
|
517
|
+
return res.fail('invalid_page', { status: 400, message: r.errors[0] });
|
|
518
|
+
}
|
|
519
|
+
const saved = await db.setBuilderBehaviorPrefs(req.builder.id, r.prefs);
|
|
520
|
+
res.json({ page, views: filterViews.listViews(saved, page) });
|
|
521
|
+
} catch (err) {
|
|
522
|
+
log.error('DELETE /me/filter-views/:id', err);
|
|
523
|
+
res.fail('write_failed', { status: 500, message: 'internal error' });
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
437
527
|
// Per-builder DISPLAY NAME (task 1669). The free label shown next to a
|
|
438
528
|
// builder's work in the hall (header, leaderboard, work feed — all already read
|
|
439
529
|
// display_name, so a DB update propagates). Seeded once from GitHub at account
|
|
@@ -857,7 +857,10 @@
|
|
|
857
857
|
]);
|
|
858
858
|
state.goals = gres.goals || [];
|
|
859
859
|
populateVersionFilter();
|
|
860
|
-
|
|
860
|
+
// task 1004553 — the opening order: my default view > the focus version > any
|
|
861
|
+
// (this page keeps no URL filter state). Rendering follows in route().
|
|
862
|
+
const views = listFilterBar && listFilterBar.views;
|
|
863
|
+
if (!(views && await views.applyDefault({ fire: false }))) applyFocusVersion(focusResp);
|
|
861
864
|
// task 1003135 — fire-and-forget: the Owner options fill when the (cached)
|
|
862
865
|
// directory resolves; boot never waits on a label lookup.
|
|
863
866
|
populateOwnerFilter().catch(() => {});
|
|
@@ -906,6 +909,7 @@
|
|
|
906
909
|
// task 1002965 adds Sort (f-goal-order — NOT f-goal-sort, which is already
|
|
907
910
|
// taken by the goal-detail To-do bar further down this file).
|
|
908
911
|
let savedGoalSort = null;
|
|
912
|
+
let listFilterBar = null;
|
|
909
913
|
let savedGoalView = null;
|
|
910
914
|
try {
|
|
911
915
|
savedGoalSort = window.localStorage.getItem(GOAL_SORT_PREF);
|
|
@@ -923,8 +927,10 @@
|
|
|
923
927
|
// two rows were the loudest thing on the page; they sit behind the kit's
|
|
924
928
|
// Filters disclosure, which counts and chips whatever is active so a hidden
|
|
925
929
|
// filter can never narrow the board silently. Control ids are unchanged.
|
|
926
|
-
makeFilterBar({
|
|
930
|
+
listFilterBar = makeFilterBar({
|
|
927
931
|
mount: $('#goals-filters'),
|
|
932
|
+
// task 1004553 — the builder's own saved views (Views menu), stored server-side.
|
|
933
|
+
views: { page: 'goals', client: api, base: `${API}/me/filter-views` },
|
|
928
934
|
search: { id: 'f-goals-search', label: 'Search', placeholder: 'goal # or name…' },
|
|
929
935
|
primary: [
|
|
930
936
|
{ id: 'f-goal-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
|
|
@@ -239,7 +239,7 @@
|
|
|
239
239
|
// value?, default? } (default falls back to the first option / '')
|
|
240
240
|
// `url: true` round-trips non-default values through the query string
|
|
241
241
|
// (history.replaceState — shareable filtered views, hash untouched).
|
|
242
|
-
function makeFilterBar({ mount, search = null, primary = [], overflow = [], url = false, onChange = null }) {
|
|
242
|
+
function makeFilterBar({ mount, search = null, primary = [], overflow = [], url = false, onChange = null, views = null }) {
|
|
243
243
|
if (!mount) throw new Error('makeFilterBar requires mount');
|
|
244
244
|
const controls = [...(search ? [{ type: 'search', ...search }] : []), ...primary, ...overflow]
|
|
245
245
|
.map((c) => ({ type: 'select', ...c, default: c.default ?? (c.type === 'search' || !c.options?.length ? '' : optVal(c.options[0])) }));
|
|
@@ -272,6 +272,8 @@
|
|
|
272
272
|
${(search ? [controls[0]] : []).map(controlHtml).join('')}
|
|
273
273
|
${primary.map((c) => controlHtml(byId.get(c.id))).join('')}
|
|
274
274
|
${overflow.length ? `<button type="button" class="btn-ghost filters__more" aria-expanded="false">Filters<span class="count-pill filters__more-count" hidden></span></button>` : ''}
|
|
275
|
+
${views ? `<span class="filter-views"><button type="button" class="btn-ghost filter-views__btn" aria-expanded="false" hidden>Views</button>
|
|
276
|
+
<div class="filter-views__menu" role="group" aria-label="Saved views" hidden></div></span>` : ''}
|
|
275
277
|
</div>
|
|
276
278
|
${overflow.length ? `<div class="filters filters--overflow" hidden>
|
|
277
279
|
${overflow.map((c) => controlHtml(byId.get(c.id))).join('')}
|
|
@@ -349,7 +351,131 @@
|
|
|
349
351
|
});
|
|
350
352
|
|
|
351
353
|
paintBadges();
|
|
352
|
-
|
|
354
|
+
const viewsApi = views ? wireViews() : null;
|
|
355
|
+
return { el: mount, values, get, set, views: viewsApi };
|
|
356
|
+
|
|
357
|
+
// ---- saved views (task 1004553) ----------------------------------------
|
|
358
|
+
// views: { page, client, base } — page is the page's short key ('goals',
|
|
359
|
+
// 'work'); client is the page's BongosClient; base is the /me/filter-views
|
|
360
|
+
// URL. Views are the builder's own, stored server-side so they follow them
|
|
361
|
+
// across devices. Opening order: URL filter > my default view > the page's
|
|
362
|
+
// own default (the focus version) > any — the page asks applyDefault() once
|
|
363
|
+
// its option lists are filled and falls back to its own default on false.
|
|
364
|
+
function wireViews() {
|
|
365
|
+
const btn = mount.querySelector('.filter-views__btn');
|
|
366
|
+
const menu = mount.querySelector('.filter-views__menu');
|
|
367
|
+
const pageQ = `page=${encodeURIComponent(views.page)}`;
|
|
368
|
+
const urlHasFilter = url && controls.some((c) => new URLSearchParams(window.location.search).has(c.id));
|
|
369
|
+
const st = { list: [], note: '' };
|
|
370
|
+
|
|
371
|
+
async function call(method, sub, body) {
|
|
372
|
+
const res = await views.client.request(method, `${views.base}${sub}`, body ? { body } : undefined);
|
|
373
|
+
if (res && res.ok) return res.data;
|
|
374
|
+
const e = res && res.data && res.data.error;
|
|
375
|
+
const err = new Error((e && e.message) || 'Could not save the view. Try again.');
|
|
376
|
+
err.status = res && res.status;
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Only what differs from the defaults is saved, so applying a view puts
|
|
381
|
+
// every other filter back to its default.
|
|
382
|
+
function currentValues() {
|
|
383
|
+
const out = {};
|
|
384
|
+
controls.forEach((c) => { const v = get(c.id); if (String(v) !== String(c.default)) out[c.id] = String(v); });
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// A value with no matching option yet is skipped rather than selected as a
|
|
389
|
+
// blank (the restoreUrlFilter rule); every other control returns to default.
|
|
390
|
+
function applyView(view, { fire = true } = {}) {
|
|
391
|
+
controls.forEach((c) => {
|
|
392
|
+
const el = mount.querySelector(`#${CSS.escape(c.id)}`);
|
|
393
|
+
if (!el) return;
|
|
394
|
+
const has = Object.prototype.hasOwnProperty.call(view.values || {}, c.id);
|
|
395
|
+
const want = String(has ? view.values[c.id] : c.default);
|
|
396
|
+
if (el.tagName === 'SELECT' && !Array.from(el.options || []).some((o) => o.value === want)) return;
|
|
397
|
+
el.value = want;
|
|
398
|
+
});
|
|
399
|
+
if (fire) changed(); else { paintBadges(); syncUrl(); }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function paint() {
|
|
403
|
+
if (!menu) return;
|
|
404
|
+
const rows = st.list.map((v) => `<li class="filter-views__row">
|
|
405
|
+
<button type="button" class="filter-views__apply" data-apply="${esc(v.id)}">${esc(v.name)}${v.is_default ? ' <span class="filter-views__tag">default</span>' : ''}</button>
|
|
406
|
+
<button type="button" class="btn-ghost" data-default="${esc(v.id)}" title="${v.is_default ? 'Stop opening this page with this view' : 'Open this page with this view'}">${v.is_default ? 'Unset default' : 'Make default'}</button>
|
|
407
|
+
<button type="button" class="btn-ghost" data-rename="${esc(v.id)}">Rename</button>
|
|
408
|
+
<button type="button" class="btn-ghost" data-delete="${esc(v.id)}">Delete</button>
|
|
409
|
+
</li>`).join('');
|
|
410
|
+
menu.innerHTML = `
|
|
411
|
+
${st.list.length ? `<ul class="filter-views__list">${rows}</ul>` : '<p class="filter-views__empty">No saved views yet. Set the filters you want, then save them here.</p>'}
|
|
412
|
+
<form class="filter-views__save">
|
|
413
|
+
<label class="filter">Save current filters as
|
|
414
|
+
<input type="text" name="name" maxlength="60" placeholder="e.g. My open goals" required></label>
|
|
415
|
+
<button type="submit" class="btn-ghost">Save view</button>
|
|
416
|
+
</form>
|
|
417
|
+
<p class="filter-views__note" role="status">${esc(st.note)}</p>`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function setList(list, note = '') { st.list = Array.isArray(list) ? list : []; st.note = note; paint(); }
|
|
421
|
+
|
|
422
|
+
async function act(fn, okNote) {
|
|
423
|
+
try { const data = await fn(); setList(data.views, okNote); } catch (err) { st.note = err.message; paint(); }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
btn?.addEventListener('click', () => {
|
|
427
|
+
const open = menu.hidden;
|
|
428
|
+
menu.hidden = !open;
|
|
429
|
+
btn.setAttribute('aria-expanded', String(open));
|
|
430
|
+
});
|
|
431
|
+
menu?.addEventListener('click', (e) => {
|
|
432
|
+
const t = e.target.closest('button');
|
|
433
|
+
if (!t) return;
|
|
434
|
+
const pick = (attr) => st.list.find((v) => String(v.id) === t.getAttribute(attr));
|
|
435
|
+
if (t.hasAttribute('data-apply')) {
|
|
436
|
+
const v = pick('data-apply');
|
|
437
|
+
if (v) { applyView(v); st.note = `Showing “${v.name}”.`; paint(); }
|
|
438
|
+
} else if (t.hasAttribute('data-default')) {
|
|
439
|
+
const v = pick('data-default');
|
|
440
|
+
if (v) act(() => call('PATCH', `/${v.id}`, { page: views.page, is_default: !v.is_default }),
|
|
441
|
+
v.is_default ? `“${v.name}” is no longer your default.` : `This page now opens with “${v.name}”.`);
|
|
442
|
+
} else if (t.hasAttribute('data-rename')) {
|
|
443
|
+
const v = pick('data-rename');
|
|
444
|
+
const name = v && window.prompt('New name for this view', v.name);
|
|
445
|
+
if (name && name.trim() && name.trim() !== v.name) act(() => call('PATCH', `/${v.id}`, { page: views.page, name }), 'Renamed.');
|
|
446
|
+
} else if (t.hasAttribute('data-delete')) {
|
|
447
|
+
const v = pick('data-delete');
|
|
448
|
+
if (v && window.confirm(`Delete the view “${v.name}”?`)) act(() => call('DELETE', `/${v.id}?${pageQ}`), 'Deleted.');
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
menu?.addEventListener('submit', (e) => {
|
|
452
|
+
e.preventDefault();
|
|
453
|
+
const input = e.target.querySelector('input[name="name"]');
|
|
454
|
+
const name = input && input.value.trim();
|
|
455
|
+
if (!name) return;
|
|
456
|
+
act(() => call('POST', '', { page: views.page, name, values: currentValues() }), `Saved “${name}”.`);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Signed-out or an older server: the button stays hidden and nothing else changes.
|
|
460
|
+
const ready = call('GET', `?${pageQ}`)
|
|
461
|
+
.then((data) => { setList(data.views); if (btn) btn.hidden = false; return true; })
|
|
462
|
+
.catch(() => false);
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
ready,
|
|
466
|
+
list: () => st.list.slice(),
|
|
467
|
+
apply: applyView,
|
|
468
|
+
// Apply my default view unless the URL already carries a filter. Resolves
|
|
469
|
+
// true when it applied one, so the page skips its own default.
|
|
470
|
+
async applyDefault({ fire = false } = {}) {
|
|
471
|
+
if (!(await ready) || urlHasFilter) return false;
|
|
472
|
+
const v = st.list.find((x) => x.is_default);
|
|
473
|
+
if (!v) return false;
|
|
474
|
+
applyView(v, { fire });
|
|
475
|
+
return true;
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
353
479
|
}
|
|
354
480
|
|
|
355
481
|
// ---- tabs ------------------------------------------------------------------
|
|
@@ -214,3 +214,8 @@
|
|
|
214
214
|
the 24px floor: the prose-link recipe buys the height with padding and hands it back
|
|
215
215
|
with an equal negative margin, so the line does not re-space. */
|
|
216
216
|
.dp-settled .ov-row__sub a { display: inline-block; min-height: 24px; padding: 3px 0; margin: -3px 0; }
|
|
217
|
+
|
|
218
|
+
/* The danger zone (task 1004570) — two links out to the hosting platform, each with
|
|
219
|
+
one sentence saying what it does. Plain list, the lede's measure. */
|
|
220
|
+
.ps-danger__list { margin: 10px 0 0; padding-left: 18px; line-height: 1.6; max-width: var(--measure); }
|
|
221
|
+
.ps-danger__list li + li { margin-top: 8px; }
|
|
@@ -49,6 +49,18 @@
|
|
|
49
49
|
thing this page exists to end. -->
|
|
50
50
|
<div id="ps-rows"><span class="kit-skel"></span></div>
|
|
51
51
|
</section>
|
|
52
|
+
<!-- Danger zone (task 1004570): where this project's OWNER takes it offline or
|
|
53
|
+
disconnects it. Both controls live on the platform that hosts the project,
|
|
54
|
+
so this only links there. Hidden unless GET /project/manage-link answers
|
|
55
|
+
with a link — an Archon on a hosted project; never on a self-hosted one. -->
|
|
56
|
+
<section class="scroll" id="ps-danger" aria-labelledby="ps-danger-h" hidden>
|
|
57
|
+
<h2 class="scroll__h" id="ps-danger-h">Danger zone</h2>
|
|
58
|
+
<p class="scroll__lede"><span id="ps-danger-lede"></span> <a id="ps-danger-manage" href="#"></a></p>
|
|
59
|
+
<ul class="ps-danger__list">
|
|
60
|
+
<li><a id="ps-danger-offline" href="#">Take it offline</a> — stops the project and its nightly backups but keeps everything: its database, its backups and your repository on GitHub. You can bring it back.</li>
|
|
61
|
+
<li><a id="ps-danger-disconnect" href="#">Disconnect from Bongos entirely</a> — erases the project’s server, its database and every backup, and releases its name and web address. Your repository on GitHub is kept. This cannot be undone.</li>
|
|
62
|
+
</ul>
|
|
63
|
+
</section>
|
|
52
64
|
</main>
|
|
53
65
|
|
|
54
66
|
<footer class="app-foot" id="app-foot"></footer>
|
|
@@ -245,5 +245,25 @@
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// The danger zone (task 1004570). The server answers only an Archon — on a hosted
|
|
249
|
+
// project, its owner — and answers { link: null } where there is no hosting platform
|
|
250
|
+
// to link to, so every other outcome (refused, failed, self-hosted) leaves the section
|
|
251
|
+
// hidden: a missing link, never a dead one. The act itself stays on the platform.
|
|
252
|
+
async function loadDanger() {
|
|
253
|
+
let link = null;
|
|
254
|
+
try {
|
|
255
|
+
const res = await api.request('GET', `${API}/project/manage-link`);
|
|
256
|
+
link = res.ok && res.data ? res.data.link : null;
|
|
257
|
+
} catch (_) { link = null; }
|
|
258
|
+
if (!link || !link.offline_url || !link.disconnect_url) return;
|
|
259
|
+
$('ps-danger-lede').textContent = `This project is hosted on ${link.hub_host}, and that is where it is taken offline or removed. Both links below open this project’s page there.`;
|
|
260
|
+
$('ps-danger-manage').textContent = `Manage this project on ${link.hub_host}`;
|
|
261
|
+
$('ps-danger-manage').href = link.manage_url;
|
|
262
|
+
$('ps-danger-offline').href = link.offline_url;
|
|
263
|
+
$('ps-danger-disconnect').href = link.disconnect_url;
|
|
264
|
+
$('ps-danger').hidden = false;
|
|
265
|
+
}
|
|
266
|
+
|
|
248
267
|
load();
|
|
268
|
+
loadDanger();
|
|
249
269
|
})();
|
|
@@ -4261,6 +4261,38 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
4261
4261
|
margin-bottom: 14px;
|
|
4262
4262
|
}
|
|
4263
4263
|
.filter-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; }
|
|
4264
|
+
/* saved views (task 1004553) — the builder's own named filter sets. The menu is
|
|
4265
|
+
a panel under the button, the overflow row's tone; it never widens the page. */
|
|
4266
|
+
.filter-views { position: relative; align-self: flex-end; }
|
|
4267
|
+
.filter-views__menu {
|
|
4268
|
+
position: absolute; z-index: 20; top: calc(100% + 6px); right: 0;
|
|
4269
|
+
width: min(360px, calc(100vw - 32px));
|
|
4270
|
+
padding: 12px 14px;
|
|
4271
|
+
border-radius: var(--radius-sm);
|
|
4272
|
+
background: var(--surface-2);
|
|
4273
|
+
box-shadow: var(--shadow-pop);
|
|
4274
|
+
}
|
|
4275
|
+
.filter-views__list { list-style: none; margin: 0 0 10px; padding: 0; }
|
|
4276
|
+
.filter-views__row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 6px 0; border-top: var(--line) solid var(--rule-soft); }
|
|
4277
|
+
.filter-views__row:first-child { border-top: 0; padding-top: 0; }
|
|
4278
|
+
.filter-views__apply {
|
|
4279
|
+
flex: 1 1 100%; min-height: 24px; padding: 0; text-align: left;
|
|
4280
|
+
background: none; border: 0; color: var(--ink); font: inherit; cursor: pointer;
|
|
4281
|
+
}
|
|
4282
|
+
.filter-views__apply:hover { text-decoration: underline; }
|
|
4283
|
+
.filter-views__tag { font-size: 11px; color: var(--ink-faint); margin-left: 6px; }
|
|
4284
|
+
.filter-views__empty, .filter-views__note { font-size: 12.5px; color: var(--ink-soft); margin: 0 0 10px; }
|
|
4285
|
+
.filter-views__note:empty { display: none; }
|
|
4286
|
+
.filter-views__row .btn-ghost { min-height: 24px; padding: 0 8px; font-size: 12px; }
|
|
4287
|
+
.filter-views__save { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; }
|
|
4288
|
+
.filter-views__save .filter { flex: 1 1 180px; }
|
|
4289
|
+
/* On a phone the button can sit anywhere in a wrapped row, so the menu spans the
|
|
4290
|
+
whole filter row instead of hanging off the button (it would leave the screen). */
|
|
4291
|
+
@media (max-width: 640px) {
|
|
4292
|
+
.filters:has(> .filter-views) { position: relative; }
|
|
4293
|
+
.filter-views { position: static; }
|
|
4294
|
+
.filter-views__menu { left: 0; right: 0; width: auto; }
|
|
4295
|
+
}
|
|
4264
4296
|
/* An active filter IS live, so the chip is the one place in a filter bar the
|
|
4265
4297
|
accent belongs. */
|
|
4266
4298
|
.chip {
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
|
|
211
211
|
// task 2170: the four sections are hash-synced tabs (Ready default). Built in
|
|
212
212
|
// wireEvents; renders feed the tab count pills via setTabCount.
|
|
213
|
-
let workTabs = null, mine = null; // mine: the Only-mine lens (mine-lens.js)
|
|
213
|
+
let workTabs = null, mine = null, workFilterBar = null; // workFilterBar: its .views is the saved-views menu (task 1004553) // mine: the Only-mine lens (mine-lens.js)
|
|
214
214
|
function setTabCount(id, n) { if (workTabs) workTabs.setCount(id, n); }
|
|
215
215
|
|
|
216
216
|
// A section-header count pill: reveal it and set its number once data lands.
|
|
@@ -999,7 +999,6 @@
|
|
|
999
999
|
taskState.versions = versionsResp.versions || [];
|
|
1000
1000
|
populateVersionFilter(taskState.versions);
|
|
1001
1001
|
restoreUrlFilter('f-version');
|
|
1002
|
-
if (applyFocusVersion(focusResp)) renderBacklog();
|
|
1003
1002
|
renderInFlight(claimResp.active_claims || []);
|
|
1004
1003
|
taskState.all = claimResp.claimable || [];
|
|
1005
1004
|
renderClaimable();
|
|
@@ -1008,6 +1007,13 @@
|
|
|
1008
1007
|
// task 2170: a shared URL's goal filter can only apply once the options
|
|
1009
1008
|
// exist — restore it now and re-render if it changed anything.
|
|
1010
1009
|
if (restoreUrlFilter('f-goal')) renderClaimable();
|
|
1010
|
+
// task 1004553 — the opening order: URL filter > my default view > the focus
|
|
1011
|
+
// version > any. Runs once the Version/Goal options exist, so a view's values
|
|
1012
|
+
// have something to select; its change event re-fetches or re-renders.
|
|
1013
|
+
const views = workFilterBar && workFilterBar.views;
|
|
1014
|
+
(views ? views.applyDefault({ fire: true }) : Promise.resolve(false)).then((applied) => {
|
|
1015
|
+
if (!applied && applyFocusVersion(focusResp)) { renderClaimable(); renderBacklog(); }
|
|
1016
|
+
});
|
|
1011
1017
|
pruneSelection();
|
|
1012
1018
|
renderBulkToolbar();
|
|
1013
1019
|
} catch (err) {
|
|
@@ -1057,8 +1063,10 @@
|
|
|
1057
1063
|
// the kit's "Filters" disclosure; state rides the URL query (shareable).
|
|
1058
1064
|
// Control ids are unchanged so renderClaimable's reads + the dynamic
|
|
1059
1065
|
// version/goal option fills keep working untouched.
|
|
1060
|
-
makeFilterBar({
|
|
1066
|
+
workFilterBar = makeFilterBar({
|
|
1061
1067
|
mount: $('#work-filters'),
|
|
1068
|
+
// task 1004553 — the builder's own saved views (Views menu), stored server-side.
|
|
1069
|
+
views: { page: 'work', client: api, base: `${API}/me/filter-views` },
|
|
1062
1070
|
search: { id: 'f-search', label: 'Search', placeholder: 'task #, title, or goal…' },
|
|
1063
1071
|
primary: [
|
|
1064
1072
|
{
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Saved filter views — the Views menu in the kit filter bar, on /goals and /work (task 1004553)
|
|
2
|
+
|
|
3
|
+
A builder saves the current filters under a name and comes back to them, on any device. What this settled:
|
|
4
|
+
|
|
5
|
+
- **Personal, and stored on the builder's own row.** The views live under the `filter_views` key of `builders.behavior_prefs` (the blob the other personal knobs share, so no migration). `/me/filter-views` only ever reads and writes `req.builder.id`'s prefs, so another builder's views are not hidden, they are unreachable. The project-wide default is the Roadmap's focus version (task 1004551), not a shared view.
|
|
6
|
+
- **It is a kit option, not page code.** `makeFilterBar({ views: { page, client, base } })` adds the Views button and menu; a page opts in with its short page key. A signed-out viewer or an older server gets no button and nothing else changes.
|
|
7
|
+
- **The opening order is URL filter > my default view > the focus version > any.** The page calls `bar.views.applyDefault()` once its option lists are filled and falls back to its focus default on `false`. A view saves only the filters that differ from their defaults, so applying one returns every other filter to its default. A saved value with no matching option yet is skipped, not selected as a blank (the `restoreUrlFilter` rule).
|
|
8
|
+
- **The default never rides the URL.** It is applied without writing the query, so a copied link carries exactly what the builder chose, and a link with a filter in it always beats the default.
|
|
9
|
+
- **Phone width.** The menu hangs off the button on desktop; at 640px and below it spans the whole filter row, because a wrapped row can put the button anywhere and the menu would leave the screen.
|
|
10
|
+
|
|
11
|
+
Tests: `tests/filter_views.mjs` (storage rules, own-scoping, both pages opting in). The preview harness serves `me__filter-views.json`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# The danger zone on /project-settings — a hosted project's owner reaches Take-offline and Delete (task 1004570)
|
|
2
|
+
|
|
3
|
+
The owner of a hosted project could not find how to delete it: both controls live on the hub's Projects page, and nothing in the project's own hall pointed there. What this settled:
|
|
4
|
+
|
|
5
|
+
- **Link-out only.** The section links to the hub's page for this project, scrolled to the `#mDelete` / `#mDisconnect` cards. The typed-name confirm and the intent queue stay on the hub (ADR 0290); the hall gains no second delete path.
|
|
6
|
+
- **The server decides who sees it.** `GET /project/manage-link` is Archon-only (on a hosted project the owner is its Archon), and answers `{ link: null }` when the instance has no hub to link to — a self-hosted instance, or the hub itself. The section is `hidden` in the markup and shown only for a real link, so every other outcome is a missing section, never a dead link.
|
|
7
|
+
- **The link names the project by slug.** The hall knows its hub origin and client id (which is its slug, ADR 0246) from its sign-in configuration, never a hardcoded domain. The hub's Projects page resolves `?view=manage&project=<slug>` against the visitor's OWN projects; a slug that is not theirs opens their list, not someone else's project. The slug survives a sign-in detour.
|
|
8
|
+
- **The two sentences track the hub's folds.** `tests/project_manage_link.mjs` holds the hall's claims (repository kept, cannot be undone, can bring it back) against the hub's own fold copy.
|
|
9
|
+
|
|
10
|
+
Tests: `tests/project_manage_link.mjs` (C1 owner link, C2 non-owner refused by the real gate, C3 self-hosted answers none). Harness fixture: `project__manage-link.json`.
|