@harshankur/viewcounter 3.1.0 → 3.2.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/.env.example +34 -10
- package/README.md +306 -136
- package/admin/css/admin.css +891 -198
- package/admin/index.html +13 -7
- package/admin/js/api.js +52 -6
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +529 -189
- package/admin/js/constants.js +98 -9
- package/admin/js/dataTable.js +478 -0
- package/admin/js/format.js +58 -7
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +2 -1
- package/admin/js/logs.js +211 -60
- package/admin/js/main.js +201 -37
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +12 -52
- package/admin/js/viewDialogs.js +30 -14
- package/admin/js/views.js +273 -207
- package/admin/locales/en.json +352 -63
- package/config/index.js +40 -5
- package/constants.js +126 -8
- package/db/AdminRepository.js +85 -159
- package/db/DatabaseManager.js +55 -7
- package/db/LogRepository.js +172 -35
- package/db/adminSchema.js +89 -4
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/index.js +53 -19
- package/middleware/adminAuth.js +83 -43
- package/middleware/adminValidation.js +69 -3
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +5 -2
- package/routes/admin.js +130 -22
- package/routes/analytics.js +197 -18
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +4 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -0
- package/admin/js/insights.js +0 -192
package/admin/js/constants.js
CHANGED
|
@@ -27,11 +27,44 @@ export const ERROR_CODE = Object.freeze({
|
|
|
27
27
|
TOO_MANY_ATTEMPTS: 'TOO_MANY_ATTEMPTS',
|
|
28
28
|
RATE_LIMITED: 'RATE_LIMITED',
|
|
29
29
|
CSRF_REJECTED: 'CSRF_REJECTED',
|
|
30
|
+
REAUTH_REQUIRED: 'REAUTH_REQUIRED',
|
|
30
31
|
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
|
31
32
|
NOT_FOUND: 'NOT_FOUND',
|
|
32
33
|
SERVER_ERROR: 'SERVER_ERROR',
|
|
33
34
|
/** Client-side only: the request never got an answer. */
|
|
34
35
|
NETWORK: 'NETWORK',
|
|
36
|
+
/** Client-side only: the page itself failed (a bug), not the server. */
|
|
37
|
+
UNEXPECTED: 'UNEXPECTED',
|
|
38
|
+
/**
|
|
39
|
+
* Client-side only: a gateway in front of the server (Cloudflare Access,
|
|
40
|
+
* say) answered with a redirect to its own sign-in page.
|
|
41
|
+
*/
|
|
42
|
+
ACCESS_EXPIRED: 'ACCESS_EXPIRED',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Using the UI extends the session at most this often (the server keeps it for days). */
|
|
46
|
+
export const SESSION_PING_INTERVAL_MS = 5 * 60 * 1000;
|
|
47
|
+
|
|
48
|
+
/** How often "Right now" on the Overview refreshes while it is on screen. */
|
|
49
|
+
export const REALTIME_REFRESH_MS = 15 * 1000;
|
|
50
|
+
|
|
51
|
+
/** How often the tracking log refreshes itself when auto-refresh is on. */
|
|
52
|
+
export const TRACKING_LOG_REFRESH_MS = 10 * 1000;
|
|
53
|
+
|
|
54
|
+
/** Where the product lives: shown in the header and footer. */
|
|
55
|
+
export const LINKS = Object.freeze({
|
|
56
|
+
WEBSITE: 'https://viewcounter.harshankur.com',
|
|
57
|
+
DOCS_ADMIN: 'https://viewcounter.harshankur.com/#admin',
|
|
58
|
+
SOURCE: 'https://github.com/harshankur/viewcounter',
|
|
59
|
+
CHANGELOG: 'https://github.com/harshankur/viewcounter/blob/master/CHANGELOG.md',
|
|
60
|
+
NPM: 'https://www.npmjs.com/package/@harshankur/viewcounter',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** The copyright notice in the footer, as the LICENSE file states it. */
|
|
64
|
+
export const COPYRIGHT = Object.freeze({
|
|
65
|
+
YEAR: 2026,
|
|
66
|
+
HOLDER: 'Harsh Ankur',
|
|
67
|
+
HOLDER_URL: 'https://harshankur.com',
|
|
35
68
|
});
|
|
36
69
|
|
|
37
70
|
/** Mirrors VIEW_STATUS on the server. */
|
|
@@ -53,12 +86,28 @@ export const SORT_ORDER = Object.freeze({
|
|
|
53
86
|
DESC: 'desc',
|
|
54
87
|
});
|
|
55
88
|
|
|
56
|
-
/** The sections of the UI. */
|
|
89
|
+
/** The sections of the UI, in order. */
|
|
57
90
|
export const TAB = Object.freeze({
|
|
91
|
+
OVERVIEW: 'overview',
|
|
58
92
|
VIEWS: 'views',
|
|
59
93
|
TRASH: 'trash',
|
|
94
|
+
TRACKING_LOG: 'trackingLog',
|
|
60
95
|
ADMIN_LOG: 'adminLog',
|
|
61
|
-
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** Each section's address after the #, so a section can be bookmarked. */
|
|
99
|
+
export const TAB_HASH = Object.freeze({
|
|
100
|
+
[TAB.OVERVIEW]: 'overview',
|
|
101
|
+
[TAB.VIEWS]: 'views',
|
|
102
|
+
[TAB.TRASH]: 'trash',
|
|
103
|
+
[TAB.TRACKING_LOG]: 'tracking-log',
|
|
104
|
+
[TAB.ADMIN_LOG]: 'admin-log',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** Addresses earlier versions used, still honoured. */
|
|
108
|
+
export const LEGACY_TAB_HASH = Object.freeze({
|
|
109
|
+
viewLog: TAB.TRACKING_LOG,
|
|
110
|
+
adminLog: TAB.ADMIN_LOG,
|
|
62
111
|
});
|
|
63
112
|
|
|
64
113
|
export const THEME = Object.freeze({
|
|
@@ -70,7 +119,28 @@ export const THEME = Object.freeze({
|
|
|
70
119
|
export const STORAGE_KEY = Object.freeze({
|
|
71
120
|
THEME: `${APP_SLUG}-admin-theme`,
|
|
72
121
|
LAST_APP: `${APP_SLUG}-admin-last-app`,
|
|
73
|
-
|
|
122
|
+
OVERVIEW_RANGE: `${APP_SLUG}-admin-overview-range`,
|
|
123
|
+
OVERVIEW_METRIC: `${APP_SLUG}-admin-overview-metric`,
|
|
124
|
+
OVERVIEW_CARDS: `${APP_SLUG}-admin-overview-cards`,
|
|
125
|
+
/** Followed by a table's name: its chosen columns, their order, and widths. */
|
|
126
|
+
TABLE_PREFIX: `${APP_SLUG}-admin-table-`,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/** Geometry of the data tables, in CSS pixels. */
|
|
130
|
+
export const TABLE = Object.freeze({
|
|
131
|
+
/** Narrower than this, a table is a list of cards. */
|
|
132
|
+
CARDS_BELOW: 600,
|
|
133
|
+
/** A card shows this many of the chosen columns; the rest are under "more". */
|
|
134
|
+
CARD_FIELDS: 5,
|
|
135
|
+
SELECT_WIDTH: 42,
|
|
136
|
+
/** The column of "more" buttons, present only when some columns do not fit. */
|
|
137
|
+
MORE_WIDTH: 40,
|
|
138
|
+
/** Narrowest and widest a column can be made. */
|
|
139
|
+
MIN_WIDTH: 72,
|
|
140
|
+
MAX_WIDTH: 720,
|
|
141
|
+
/** Arrow keys on a column edge move it this far; with Shift, further. */
|
|
142
|
+
RESIZE_STEP: 16,
|
|
143
|
+
RESIZE_STEP_LARGE: 64,
|
|
74
144
|
});
|
|
75
145
|
|
|
76
146
|
export const TOAST_DURATION_MS = 4000;
|
|
@@ -80,14 +150,26 @@ export const SEARCH_DEBOUNCE_MS = 300;
|
|
|
80
150
|
|
|
81
151
|
export const DEFAULT_LOCALE = 'en';
|
|
82
152
|
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
'
|
|
153
|
+
/**
|
|
154
|
+
* Every field a view row exposes, grouped for the details dialog. The group
|
|
155
|
+
* key names its heading (details.groups.*).
|
|
156
|
+
*/
|
|
157
|
+
export const VIEW_DETAIL_GROUPS = Object.freeze([
|
|
158
|
+
Object.freeze({ key: 'view', fields: Object.freeze(['id', 'timestamp', 'eventType', 'eventData', 'isUnique', 'sessionId']) }),
|
|
159
|
+
Object.freeze({ key: 'page', fields: Object.freeze(['hostname', 'pagePath', 'pageTitle']) }),
|
|
160
|
+
Object.freeze({ key: 'source', fields: Object.freeze([
|
|
161
|
+
'sourceType', 'referrer', 'referrerDomain', 'utmSource', 'utmMedium', 'utmCampaign', 'utmTerm', 'utmContent',
|
|
162
|
+
]) }),
|
|
163
|
+
Object.freeze({ key: 'visitor', fields: Object.freeze([
|
|
164
|
+
'country', 'region', 'city', 'language', 'maskedIp', 'deviceSize', 'deviceType', 'browser', 'browserVersion', 'os', 'osVersion',
|
|
165
|
+
]) }),
|
|
166
|
+
Object.freeze({ key: 'engagement', fields: Object.freeze(['engagedMs', 'scrollDepth']) }),
|
|
167
|
+
Object.freeze({ key: 'admin', fields: Object.freeze(['note', 'adminModifiedAt', 'deletedAt']) }),
|
|
89
168
|
]);
|
|
90
169
|
|
|
170
|
+
/** Every field a view row exposes, in display order. */
|
|
171
|
+
export const VIEW_DETAIL_FIELDS = Object.freeze(VIEW_DETAIL_GROUPS.flatMap((group) => group.fields));
|
|
172
|
+
|
|
91
173
|
/** Editable fields rendered as free text, and those rendered otherwise. */
|
|
92
174
|
export const TEXT_FIELDS = Object.freeze(['pagePath', 'pageTitle', 'referrer', 'eventType']);
|
|
93
175
|
export const DEVICE_SIZE_FIELD = 'deviceSize';
|
|
@@ -119,6 +201,7 @@ export const ALL_APPS = '*';
|
|
|
119
201
|
|
|
120
202
|
/** Mirrors ADMIN_RANGE on the server. */
|
|
121
203
|
export const RANGE = Object.freeze({
|
|
204
|
+
DAY: '24h',
|
|
122
205
|
WEEK: '7d',
|
|
123
206
|
MONTH: '30d',
|
|
124
207
|
QUARTER: '90d',
|
|
@@ -133,6 +216,12 @@ export const CHART = Object.freeze({
|
|
|
133
216
|
HEIGHT: 220,
|
|
134
217
|
MARGIN: Object.freeze({ top: 12, right: 12, bottom: 28, left: 44 }),
|
|
135
218
|
}),
|
|
219
|
+
/** A stat tile's sparkline. */
|
|
220
|
+
SPARK: Object.freeze({ WIDTH: 120, HEIGHT: 28, PAD: 3 }),
|
|
221
|
+
/** "Right now": one column per minute. */
|
|
222
|
+
MINUTES: Object.freeze({ WIDTH: 300, HEIGHT: 56, GAP: 2, SPAN: 30 }),
|
|
223
|
+
/** Label every third hour across the heatmap. */
|
|
224
|
+
HEATMAP_HOUR_LABEL_EVERY: 3,
|
|
136
225
|
Y_TICKS: 4,
|
|
137
226
|
TICK_GAP: 8,
|
|
138
227
|
X_LABEL_GAP: 18,
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A data table whose columns the admin chooses, orders, and sizes.
|
|
3
|
+
*
|
|
4
|
+
* The admin picks which columns matter, and in what order. The table then
|
|
5
|
+
* shows as many of them as fit its width, in that order, and keeps the rest
|
|
6
|
+
* of each row one tap away, under it. A wider window shows more columns, a
|
|
7
|
+
* narrow one fewer, a phone shows the first few as a card, and nothing ever
|
|
8
|
+
* scrolls sideways (FRONTEND.md §12: reflow, never a horizontal scrollbar).
|
|
9
|
+
*
|
|
10
|
+
* Column edges drag to resize, by mouse, touch, or keyboard. Choices are
|
|
11
|
+
* remembered per table in this browser.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { el, replaceChildren, uniqueId } from './dom.js';
|
|
15
|
+
import { icon } from './icons.js';
|
|
16
|
+
import { t } from './i18n.js';
|
|
17
|
+
import { openModal, VARIANT } from './modal.js';
|
|
18
|
+
import { KEY, SORT_ORDER, STORAGE_KEY, TABLE } from './constants.js';
|
|
19
|
+
|
|
20
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a table remembers: the columns in the admin's order, which of them are
|
|
24
|
+
* hidden, and any widths set by hand.
|
|
25
|
+
* @param {string} storageKey
|
|
26
|
+
* @param {Array<{ id: string, hidden?: boolean }>} columns
|
|
27
|
+
*/
|
|
28
|
+
function loadPreferences(storageKey, columns) {
|
|
29
|
+
const defaults = () => ({
|
|
30
|
+
order: columns.map((column) => column.id),
|
|
31
|
+
hidden: new Set(columns.filter((column) => column.hidden).map((column) => column.id)),
|
|
32
|
+
widths: {},
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY.TABLE_PREFIX + storageKey));
|
|
36
|
+
if (!stored || !Array.isArray(stored.order)) return { ...defaults(), defaults };
|
|
37
|
+
const known = new Set(columns.map((column) => column.id));
|
|
38
|
+
const order = stored.order.filter((id) => known.has(id));
|
|
39
|
+
const hidden = new Set((stored.hidden || []).filter((id) => known.has(id)));
|
|
40
|
+
// A column added since the choice was saved joins where it sits by
|
|
41
|
+
// default, shown or hidden as it is by default.
|
|
42
|
+
columns.forEach((column, index) => {
|
|
43
|
+
if (order.includes(column.id)) return;
|
|
44
|
+
order.splice(Math.min(index, order.length), 0, column.id);
|
|
45
|
+
if (column.hidden) hidden.add(column.id);
|
|
46
|
+
});
|
|
47
|
+
const widths = {};
|
|
48
|
+
for (const [id, width] of Object.entries(stored.widths || {})) {
|
|
49
|
+
if (known.has(id) && Number.isFinite(width)) widths[id] = width;
|
|
50
|
+
}
|
|
51
|
+
return { order, hidden, widths, defaults };
|
|
52
|
+
} catch {
|
|
53
|
+
return { ...defaults(), defaults };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function savePreferences(storageKey, { order, hidden, widths }) {
|
|
58
|
+
try {
|
|
59
|
+
localStorage.setItem(STORAGE_KEY.TABLE_PREFIX + storageKey, JSON.stringify({ order, hidden: [...hidden], widths }));
|
|
60
|
+
} catch {
|
|
61
|
+
// Storage blocked: the choice lasts until the page is reloaded.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{
|
|
67
|
+
* storageKey: string,
|
|
68
|
+
* columns: Array<{ id: string, label: string, width: number, minWidth?: number, grow?: boolean, hidden?: boolean,
|
|
69
|
+
* sortKey?: string, available?: () => boolean, cell: (row: object) => Array<Node|string|null> }>,
|
|
70
|
+
* rowKey: (row: object) => string,
|
|
71
|
+
* rowAttrs?: (row: object) => { className?: string, dataset?: Record<string, string> },
|
|
72
|
+
* select?: { header: () => Node, cell: (row: object) => Node },
|
|
73
|
+
* actions?: { label: string, width: number, cell: (row: object) => Node },
|
|
74
|
+
* sort?: () => { key: string, order: string }, onSort?: (key: string) => void,
|
|
75
|
+
* empty: () => string, className?: string, id?: string,
|
|
76
|
+
* }} options
|
|
77
|
+
* `columns` are in their default order; `hidden` ones start unchosen; the
|
|
78
|
+
* first shown column with `grow` takes the spare width.
|
|
79
|
+
*/
|
|
80
|
+
export function createDataTable({
|
|
81
|
+
storageKey, columns, rowKey, rowAttrs = () => ({}), select, actions, sort, onSort, empty, className = '', id,
|
|
82
|
+
}) {
|
|
83
|
+
const byId = new Map(columns.map((column) => [column.id, column]));
|
|
84
|
+
const prefs = loadPreferences(storageKey, columns);
|
|
85
|
+
const state = { rows: [], expanded: new Set(), signature: '', layout: null, focusHandle: null };
|
|
86
|
+
let afterRender = () => {};
|
|
87
|
+
|
|
88
|
+
const thead = el('thead');
|
|
89
|
+
const tbody = el('tbody');
|
|
90
|
+
const table = el('table', { className: `data-table ${className}`.trim(), attrs: { id: id ?? null } }, [thead, tbody]);
|
|
91
|
+
const wrap = el('div', { className: 'table-wrap' }, [table]);
|
|
92
|
+
|
|
93
|
+
const minWidthOf = (column) => column.minWidth ?? TABLE.MIN_WIDTH;
|
|
94
|
+
const widthOf = (column) => clamp(prefs.widths[column.id] ?? column.width, minWidthOf(column), TABLE.MAX_WIDTH);
|
|
95
|
+
const chosenColumns = () => prefs.order
|
|
96
|
+
.filter((columnId) => !prefs.hidden.has(columnId))
|
|
97
|
+
.map((columnId) => byId.get(columnId))
|
|
98
|
+
.filter((column) => column && (column.available ? column.available() : true));
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Which chosen columns fit, in order; the rest go under each row. Below
|
|
102
|
+
* the card width, the first few are a card and the rest go under it.
|
|
103
|
+
*/
|
|
104
|
+
function computeLayout() {
|
|
105
|
+
const available = wrap.clientWidth;
|
|
106
|
+
if (!available) return null;
|
|
107
|
+
const chosen = chosenColumns();
|
|
108
|
+
if (available < TABLE.CARDS_BELOW) {
|
|
109
|
+
return { cards: true, shown: chosen.slice(0, TABLE.CARD_FIELDS), more: chosen.slice(TABLE.CARD_FIELDS), fill: null };
|
|
110
|
+
}
|
|
111
|
+
const fixed = (select ? TABLE.SELECT_WIDTH : 0) + (actions ? actions.width : 0);
|
|
112
|
+
const fit = (reserved) => {
|
|
113
|
+
let used = fixed + reserved;
|
|
114
|
+
const shown = [];
|
|
115
|
+
for (const column of chosen) {
|
|
116
|
+
const width = widthOf(column);
|
|
117
|
+
// The first column always shows; after it, strictly in order.
|
|
118
|
+
if (shown.length && used + width > available) break;
|
|
119
|
+
shown.push(column);
|
|
120
|
+
used += width;
|
|
121
|
+
}
|
|
122
|
+
return shown;
|
|
123
|
+
};
|
|
124
|
+
let shown = fit(0);
|
|
125
|
+
if (shown.length < chosen.length) shown = fit(TABLE.MORE_WIDTH);
|
|
126
|
+
return { cards: false, shown, more: chosen.slice(shown.length), fill: shown.find((column) => column.grow) ?? shown.at(-1) ?? null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- Head ---------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function headerCell(column, layout) {
|
|
132
|
+
const current = sort ? sort() : null;
|
|
133
|
+
const sortable = Boolean(column.sortKey && onSort);
|
|
134
|
+
const active = sortable && current?.key === column.sortKey;
|
|
135
|
+
const ascending = current?.order === SORT_ORDER.ASC;
|
|
136
|
+
const label = sortable
|
|
137
|
+
? el('button', {
|
|
138
|
+
className: `sort-btn${active ? ' active' : ''}`,
|
|
139
|
+
attrs: { type: 'button' },
|
|
140
|
+
dataset: { sortKey: column.sortKey },
|
|
141
|
+
on: { click: () => onSort(column.sortKey) },
|
|
142
|
+
}, [el('span', { text: column.label }), icon(active ? (ascending ? 'arrowUp' : 'arrowDown') : 'sort', { className: 'sort-indicator' })])
|
|
143
|
+
: el('span', { className: 'th-label', text: column.label });
|
|
144
|
+
|
|
145
|
+
const th = el('th', {
|
|
146
|
+
className: `col-${column.id}`,
|
|
147
|
+
attrs: { scope: 'col', 'aria-sort': sortable ? (active ? (ascending ? 'ascending' : 'descending') : 'none') : null },
|
|
148
|
+
dataset: { column: column.id },
|
|
149
|
+
}, [label, layout.cards ? null : resizeHandle(column)]);
|
|
150
|
+
if (!layout.cards && column !== layout.fill) th.style.width = `${widthOf(column)}px`;
|
|
151
|
+
return th;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The draggable right edge of a header: pointer, touch, or arrow keys. */
|
|
155
|
+
function resizeHandle(column) {
|
|
156
|
+
const handle = el('span', {
|
|
157
|
+
className: 'resize-handle',
|
|
158
|
+
attrs: {
|
|
159
|
+
role: 'separator',
|
|
160
|
+
tabindex: 0,
|
|
161
|
+
'aria-orientation': 'vertical',
|
|
162
|
+
'aria-label': t('table.resize', { column: column.label }),
|
|
163
|
+
'aria-valuemin': minWidthOf(column),
|
|
164
|
+
'aria-valuemax': TABLE.MAX_WIDTH,
|
|
165
|
+
'aria-valuenow': Math.round(widthOf(column)),
|
|
166
|
+
title: t('table.resizeHint'),
|
|
167
|
+
},
|
|
168
|
+
dataset: { resize: column.id },
|
|
169
|
+
});
|
|
170
|
+
const setWidth = (width) => {
|
|
171
|
+
prefs.widths[column.id] = clamp(Math.round(width), minWidthOf(column), TABLE.MAX_WIDTH);
|
|
172
|
+
savePreferences(storageKey, prefs);
|
|
173
|
+
// A rebuild replaces this handle; a keyboard user keeps their place.
|
|
174
|
+
state.focusHandle = document.activeElement === handle ? column.id : null;
|
|
175
|
+
refresh();
|
|
176
|
+
};
|
|
177
|
+
const reset = () => {
|
|
178
|
+
delete prefs.widths[column.id];
|
|
179
|
+
savePreferences(storageKey, prefs);
|
|
180
|
+
state.focusHandle = column.id;
|
|
181
|
+
refresh();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
handle.addEventListener('pointerdown', (event) => {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
// Start from the width on screen: the column taking the spare room
|
|
187
|
+
// is wider than the width it asks for.
|
|
188
|
+
const startWidth = handle.parentElement.getBoundingClientRect().width;
|
|
189
|
+
const startX = event.clientX;
|
|
190
|
+
document.body.classList.add('resizing-column');
|
|
191
|
+
const onMove = (move) => setWidth(startWidth + (move.clientX - startX));
|
|
192
|
+
const onUp = () => {
|
|
193
|
+
document.body.classList.remove('resizing-column');
|
|
194
|
+
window.removeEventListener('pointermove', onMove);
|
|
195
|
+
window.removeEventListener('pointerup', onUp);
|
|
196
|
+
window.removeEventListener('pointercancel', onUp);
|
|
197
|
+
};
|
|
198
|
+
window.addEventListener('pointermove', onMove);
|
|
199
|
+
window.addEventListener('pointerup', onUp);
|
|
200
|
+
window.addEventListener('pointercancel', onUp);
|
|
201
|
+
});
|
|
202
|
+
handle.addEventListener('dblclick', reset);
|
|
203
|
+
handle.addEventListener('keydown', (event) => {
|
|
204
|
+
const step = event.shiftKey ? TABLE.RESIZE_STEP_LARGE : TABLE.RESIZE_STEP;
|
|
205
|
+
const current = handle.parentElement.getBoundingClientRect().width;
|
|
206
|
+
if (event.key === KEY.ARROW_RIGHT) setWidth(current + step);
|
|
207
|
+
else if (event.key === KEY.ARROW_LEFT) setWidth(current - step);
|
|
208
|
+
else if (event.key === KEY.HOME) setWidth(minWidthOf(column));
|
|
209
|
+
else if (event.key === KEY.ENTER) reset();
|
|
210
|
+
else return;
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
});
|
|
213
|
+
return handle;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function renderHead(layout) {
|
|
217
|
+
const cells = [];
|
|
218
|
+
if (select) {
|
|
219
|
+
const th = el('th', { className: 'col-select', attrs: { scope: 'col' } }, [select.header()]);
|
|
220
|
+
th.style.width = `${TABLE.SELECT_WIDTH}px`;
|
|
221
|
+
cells.push(th);
|
|
222
|
+
}
|
|
223
|
+
if (layout.more.length && !layout.cards) {
|
|
224
|
+
const all = state.rows.length > 0 && state.rows.every((row) => state.expanded.has(rowKey(row)));
|
|
225
|
+
const names = layout.more.map((column) => column.label).join(', ');
|
|
226
|
+
const th = el('th', { className: 'col-more', attrs: { scope: 'col' } }, [el('button', {
|
|
227
|
+
className: 'icon-btn more-toggle',
|
|
228
|
+
attrs: {
|
|
229
|
+
type: 'button',
|
|
230
|
+
'aria-expanded': String(all),
|
|
231
|
+
'aria-label': t(all ? 'table.hideMoreAll' : 'table.showMoreAll', { columns: names }),
|
|
232
|
+
title: t(all ? 'table.hideMoreAll' : 'table.showMoreAll', { columns: names }),
|
|
233
|
+
},
|
|
234
|
+
on: { click: () => {
|
|
235
|
+
if (all) state.expanded.clear();
|
|
236
|
+
else for (const row of state.rows) state.expanded.add(rowKey(row));
|
|
237
|
+
render();
|
|
238
|
+
thead.querySelector('.more-toggle')?.focus();
|
|
239
|
+
} },
|
|
240
|
+
}, [icon(all ? 'chevronDown' : 'chevronRight')])]);
|
|
241
|
+
th.style.width = `${TABLE.MORE_WIDTH}px`;
|
|
242
|
+
cells.push(th);
|
|
243
|
+
}
|
|
244
|
+
cells.push(...layout.shown.map((column) => headerCell(column, layout)));
|
|
245
|
+
if (actions) {
|
|
246
|
+
const th = el('th', { className: 'col-actions', text: actions.label, attrs: { scope: 'col' } });
|
|
247
|
+
th.style.width = `${actions.width}px`;
|
|
248
|
+
cells.push(th);
|
|
249
|
+
}
|
|
250
|
+
replaceChildren(thead, [el('tr', {}, cells)]);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- Rows ---------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function moreButton(row, layout, key) {
|
|
256
|
+
const open = state.expanded.has(key);
|
|
257
|
+
const labelKey = open ? 'table.hideMore' : 'table.showMore';
|
|
258
|
+
return el('button', {
|
|
259
|
+
className: layout.cards ? 'btn btn-ghost btn-small more-toggle' : 'icon-btn more-toggle',
|
|
260
|
+
attrs: {
|
|
261
|
+
type: 'button',
|
|
262
|
+
'aria-expanded': String(open),
|
|
263
|
+
'aria-label': t(labelKey, { count: layout.more.length }),
|
|
264
|
+
title: layout.cards ? null : t(labelKey, { count: layout.more.length }),
|
|
265
|
+
},
|
|
266
|
+
on: { click: () => {
|
|
267
|
+
if (open) state.expanded.delete(key);
|
|
268
|
+
else state.expanded.add(key);
|
|
269
|
+
render();
|
|
270
|
+
[...tbody.querySelectorAll('tr[data-row-key]')].find((tr) => tr.dataset.rowKey === key)
|
|
271
|
+
?.querySelector('.more-toggle')?.focus();
|
|
272
|
+
} },
|
|
273
|
+
}, [icon(open ? 'chevronDown' : 'chevronRight'), layout.cards ? el('span', { text: t(labelKey, { count: layout.more.length }) }) : null]);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderRow(row, layout, columnCount) {
|
|
277
|
+
const key = rowKey(row);
|
|
278
|
+
const { className: rowClass = '', dataset = {} } = rowAttrs(row);
|
|
279
|
+
const cells = [];
|
|
280
|
+
if (select) cells.push(el('td', { className: 'col-select' }, [select.cell(row)]));
|
|
281
|
+
const more = layout.more.length ? el('td', { className: 'col-more' }, [moreButton(row, layout, key)]) : null;
|
|
282
|
+
if (more && !layout.cards) cells.push(more);
|
|
283
|
+
for (const column of layout.shown) {
|
|
284
|
+
cells.push(el('td', { className: `col-${column.id}`, dataset: { label: column.label } }, column.cell(row)));
|
|
285
|
+
}
|
|
286
|
+
if (more && layout.cards) cells.push(more);
|
|
287
|
+
if (actions) cells.push(el('td', { className: 'col-actions' }, [actions.cell(row)]));
|
|
288
|
+
|
|
289
|
+
const rows = [el('tr', { className: rowClass, dataset: { ...dataset, rowKey: key } }, cells)];
|
|
290
|
+
if (layout.more.length && state.expanded.has(key)) {
|
|
291
|
+
// What did not fit, in the admin's order, under the row it belongs to.
|
|
292
|
+
rows.push(el('tr', { className: `row-more ${rowClass}`.trim() }, [el('td', { attrs: { colspan: String(columnCount) } }, [
|
|
293
|
+
el('dl', { className: 'more-list' }, layout.more.flatMap((column) => [
|
|
294
|
+
el('dt', { text: column.label }),
|
|
295
|
+
el('dd', { className: `col-${column.id}` }, column.cell(row)),
|
|
296
|
+
])),
|
|
297
|
+
])]));
|
|
298
|
+
}
|
|
299
|
+
return rows;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function render() {
|
|
303
|
+
const layout = computeLayout();
|
|
304
|
+
if (!layout) return;
|
|
305
|
+
state.layout = layout;
|
|
306
|
+
state.signature = signatureOf(layout);
|
|
307
|
+
table.classList.toggle('cards', layout.cards);
|
|
308
|
+
table.classList.toggle('has-more', layout.more.length > 0);
|
|
309
|
+
renderHead(layout);
|
|
310
|
+
const columnCount = layout.shown.length + (select ? 1 : 0) + (actions ? 1 : 0) + (layout.more.length ? 1 : 0);
|
|
311
|
+
if (state.rows.length === 0) {
|
|
312
|
+
replaceChildren(tbody, [el('tr', {}, [el('td', { className: 'table-message', text: empty(), attrs: { colspan: String(columnCount) } })])]);
|
|
313
|
+
} else {
|
|
314
|
+
replaceChildren(tbody, state.rows.flatMap((row) => renderRow(row, layout, columnCount)));
|
|
315
|
+
}
|
|
316
|
+
if (state.focusHandle) {
|
|
317
|
+
thead.querySelector(`[data-resize="${state.focusHandle}"]`)?.focus({ preventScroll: true });
|
|
318
|
+
state.focusHandle = null;
|
|
319
|
+
}
|
|
320
|
+
afterRender();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const signatureOf = (layout) => [
|
|
324
|
+
layout.cards ? 'cards' : 'table',
|
|
325
|
+
layout.shown.map((column) => column.id).join(','),
|
|
326
|
+
layout.more.map((column) => column.id).join(','),
|
|
327
|
+
layout.fill?.id ?? '',
|
|
328
|
+
].join('|');
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Re-fit after the width, or a column's width, changed. Rebuilds only when
|
|
332
|
+
* what is shown changes; otherwise it just sets widths, so dragging an
|
|
333
|
+
* edge stays smooth.
|
|
334
|
+
*/
|
|
335
|
+
function refresh() {
|
|
336
|
+
const layout = computeLayout();
|
|
337
|
+
if (!layout) return;
|
|
338
|
+
if (signatureOf(layout) !== state.signature) {
|
|
339
|
+
render();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
state.layout = layout;
|
|
343
|
+
for (const column of layout.shown) {
|
|
344
|
+
const th = thead.querySelector(`th[data-column="${column.id}"]`);
|
|
345
|
+
if (!th) continue;
|
|
346
|
+
th.style.width = layout.cards || column === layout.fill ? '' : `${widthOf(column)}px`;
|
|
347
|
+
th.querySelector('.resize-handle')?.setAttribute('aria-valuenow', String(Math.round(widthOf(column))));
|
|
348
|
+
}
|
|
349
|
+
if (state.focusHandle) {
|
|
350
|
+
thead.querySelector(`[data-resize="${state.focusHandle}"]`)?.focus({ preventScroll: true });
|
|
351
|
+
state.focusHandle = null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let frame = null;
|
|
356
|
+
new ResizeObserver(() => {
|
|
357
|
+
if (frame) return;
|
|
358
|
+
frame = window.requestAnimationFrame(() => {
|
|
359
|
+
frame = null;
|
|
360
|
+
refresh();
|
|
361
|
+
});
|
|
362
|
+
}).observe(wrap);
|
|
363
|
+
|
|
364
|
+
// ---- Choosing columns ---------------------------------------------------
|
|
365
|
+
|
|
366
|
+
function openChooser() {
|
|
367
|
+
let draft = { order: [...prefs.order], hidden: new Set(prefs.hidden), widths: { ...prefs.widths } };
|
|
368
|
+
const list = el('ol', { className: 'column-chooser' });
|
|
369
|
+
const error = el('p', { className: 'field-error', attrs: { role: 'alert' } });
|
|
370
|
+
error.hidden = true;
|
|
371
|
+
|
|
372
|
+
function renderList(focus) {
|
|
373
|
+
replaceChildren(list, draft.order.map((columnId, index) => {
|
|
374
|
+
const column = byId.get(columnId);
|
|
375
|
+
const checkbox = el('input', {
|
|
376
|
+
attrs: { type: 'checkbox' },
|
|
377
|
+
on: { change: (event) => {
|
|
378
|
+
if (event.currentTarget.checked) draft.hidden.delete(columnId);
|
|
379
|
+
else draft.hidden.add(columnId);
|
|
380
|
+
error.hidden = true;
|
|
381
|
+
} },
|
|
382
|
+
});
|
|
383
|
+
checkbox.checked = !draft.hidden.has(columnId);
|
|
384
|
+
const move = (delta, labelKey, iconName) => el('button', {
|
|
385
|
+
className: 'icon-btn',
|
|
386
|
+
attrs: {
|
|
387
|
+
type: 'button',
|
|
388
|
+
'aria-label': t(labelKey, { column: column.label }),
|
|
389
|
+
title: t(labelKey, { column: column.label }),
|
|
390
|
+
disabled: index + delta < 0 || index + delta >= draft.order.length,
|
|
391
|
+
},
|
|
392
|
+
dataset: { move: `${columnId}:${delta}` },
|
|
393
|
+
on: { click: () => {
|
|
394
|
+
const order = [...draft.order];
|
|
395
|
+
[order[index], order[index + delta]] = [order[index + delta], order[index]];
|
|
396
|
+
draft.order = order;
|
|
397
|
+
renderList(`${columnId}:${delta}`);
|
|
398
|
+
} },
|
|
399
|
+
}, [icon(iconName)]);
|
|
400
|
+
const unavailable = column.available && !column.available();
|
|
401
|
+
return el('li', { className: 'column-choice', dataset: { column: columnId } }, [
|
|
402
|
+
el('label', { className: 'column-choice-label' }, [
|
|
403
|
+
checkbox,
|
|
404
|
+
el('span', { text: column.label }),
|
|
405
|
+
unavailable && column.unavailableNote ? el('span', { className: 'column-choice-note', text: column.unavailableNote }) : null,
|
|
406
|
+
]),
|
|
407
|
+
move(-1, 'table.moveUp', 'arrowUp'),
|
|
408
|
+
move(1, 'table.moveDown', 'arrowDown'),
|
|
409
|
+
]);
|
|
410
|
+
}));
|
|
411
|
+
if (focus) {
|
|
412
|
+
// Keep the focus on the button just used, or its twin at an end of the list.
|
|
413
|
+
const [columnId, delta] = focus.split(':');
|
|
414
|
+
const same = list.querySelector(`[data-move="${columnId}:${delta}"]`);
|
|
415
|
+
(same && !same.disabled ? same : list.querySelector(`[data-move="${columnId}:${-Number(delta)}"]`))?.focus();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
renderList();
|
|
419
|
+
|
|
420
|
+
openModal({
|
|
421
|
+
title: t('table.columnsTitle'),
|
|
422
|
+
body: [el('p', { className: 'modal-message', text: t('table.columnsIntro') }), list, error],
|
|
423
|
+
actions: [
|
|
424
|
+
{ label: t('table.columnsReset'), onClick: () => {
|
|
425
|
+
const fresh = prefs.defaults();
|
|
426
|
+
draft = { order: fresh.order, hidden: fresh.hidden, widths: {} };
|
|
427
|
+
error.hidden = true;
|
|
428
|
+
renderList();
|
|
429
|
+
return false;
|
|
430
|
+
} },
|
|
431
|
+
{ label: t('common.cancel') },
|
|
432
|
+
{ label: t('table.columnsApply'), variant: VARIANT.PRIMARY, onClick: () => {
|
|
433
|
+
if (draft.order.every((columnId) => draft.hidden.has(columnId))) {
|
|
434
|
+
error.textContent = t('table.columnsNone');
|
|
435
|
+
error.hidden = false;
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
prefs.order = draft.order;
|
|
439
|
+
prefs.hidden = draft.hidden;
|
|
440
|
+
prefs.widths = draft.widths;
|
|
441
|
+
savePreferences(storageKey, prefs);
|
|
442
|
+
render();
|
|
443
|
+
return true;
|
|
444
|
+
} },
|
|
445
|
+
],
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const chooserId = uniqueId('columns-button');
|
|
450
|
+
const chooserButton = el('button', {
|
|
451
|
+
className: 'btn btn-secondary btn-small columns-button',
|
|
452
|
+
attrs: { type: 'button', id: chooserId, 'aria-haspopup': 'dialog' },
|
|
453
|
+
on: { click: openChooser },
|
|
454
|
+
}, [icon('columns'), el('span', { text: t('table.columns') })]);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
/** The bordered box holding the table. */
|
|
458
|
+
element: wrap,
|
|
459
|
+
table,
|
|
460
|
+
/** The "Columns" button, for the toolbar's view controls. */
|
|
461
|
+
chooserButton,
|
|
462
|
+
/** Show these rows. Rows that are gone lose their expanded state. */
|
|
463
|
+
setRows(rows) {
|
|
464
|
+
state.rows = rows;
|
|
465
|
+
const keys = new Set(rows.map(rowKey));
|
|
466
|
+
for (const key of state.expanded) if (!keys.has(key)) state.expanded.delete(key);
|
|
467
|
+
render();
|
|
468
|
+
},
|
|
469
|
+
/** Redraw as is: after a sort change, or when a column's availability changed. */
|
|
470
|
+
render,
|
|
471
|
+
/** Run after every redraw, for state the caller keeps on the rows (a selection, say). */
|
|
472
|
+
onRender(callback) {
|
|
473
|
+
afterRender = callback;
|
|
474
|
+
},
|
|
475
|
+
/** The sort header button for a key, to return focus to it after sorting. */
|
|
476
|
+
sortButton: (key) => thead.querySelector(`[data-sort-key="${key}"]`),
|
|
477
|
+
};
|
|
478
|
+
}
|