@harshankur/viewcounter 3.1.0 → 3.3.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 +37 -11
- package/README.md +330 -138
- 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 +57 -7
- package/db/LogRepository.js +172 -35
- package/db/adminSchema.js +93 -4
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +484 -0
- package/db/rejectionCounter.js +117 -0
- package/index.js +49 -26
- 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 +236 -18
- package/tracker/tracker.js +240 -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/views.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The views table, used twice: the Views tab (live rows, optionally with the
|
|
3
|
-
* trashed ones shown inline) and the Trash tab (trashed rows only).
|
|
3
|
+
* trashed ones shown inline) and the Trash tab (trashed rows only). These
|
|
4
|
+
* rows are the data every statistic on the Overview is computed from.
|
|
4
5
|
*
|
|
5
6
|
* Selection is a set of view IDs that survives paging, so a batch can span
|
|
6
7
|
* pages, and is cleared whenever the query changes shape (app, filter,
|
|
@@ -8,20 +9,22 @@
|
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { api } from './api.js';
|
|
12
|
+
import { appEntries, createAppTabs } from './appTabs.js';
|
|
13
|
+
import { regionName } from './charts.js';
|
|
11
14
|
import { clampText } from './clamp.js';
|
|
15
|
+
import { createDataTable } from './dataTable.js';
|
|
12
16
|
import { el, replaceChildren, debounce, uniqueId } from './dom.js';
|
|
13
|
-
import { formatDateTime, formatNumber, orNone } from './format.js';
|
|
17
|
+
import { formatDate, formatDateTime, formatDuration, formatNumber, formatTime, orNone } from './format.js';
|
|
18
|
+
import { icon } from './icons.js';
|
|
14
19
|
import { t, tOr } from './i18n.js';
|
|
15
20
|
import { createListbox } from './listbox.js';
|
|
16
21
|
import { confirmModal } from './modal.js';
|
|
17
|
-
import { createPager, createSegmented
|
|
22
|
+
import { createPager, createSegmented } from './table.js';
|
|
18
23
|
import { showToast, TOAST_TYPE } from './toast.js';
|
|
19
24
|
import { openDetails, openEditor, openNoteEditor } from './viewDialogs.js';
|
|
20
|
-
import {
|
|
25
|
+
import { valueLabel } from './overview.js';
|
|
21
26
|
import {
|
|
22
27
|
ALL_APPS,
|
|
23
|
-
CLAMP_LINES,
|
|
24
|
-
KEY,
|
|
25
28
|
RANGE,
|
|
26
29
|
MODIFIED_FILTER,
|
|
27
30
|
SEARCH_DEBOUNCE_MS,
|
|
@@ -35,25 +38,93 @@ export const PANEL_MODE = Object.freeze({
|
|
|
35
38
|
TRASH: 'trash',
|
|
36
39
|
});
|
|
37
40
|
|
|
38
|
-
/**
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
/** Width of the row actions column: four icon buttons and their padding. */
|
|
42
|
+
const ACTIONS_WIDTH = 148;
|
|
43
|
+
|
|
44
|
+
/** A cell of two lines: what matters, and a detail under it. */
|
|
45
|
+
const two = (primary, secondary) => [
|
|
46
|
+
clampText(primary, { className: 'cell-primary' }),
|
|
47
|
+
clampText(secondary, { className: 'cell-secondary' }),
|
|
48
|
+
];
|
|
49
|
+
/** A cell of one line. */
|
|
50
|
+
const one = (text, className = 'cell-primary') => [clampText(text, { className })];
|
|
51
|
+
const when = (value) => (value ? two(formatDate(value), formatTime(value)) : one(t('common.none')));
|
|
52
|
+
|
|
53
|
+
/** A custom event's data in a line: its first few simple values. */
|
|
54
|
+
function eventSummary(data) {
|
|
55
|
+
if (!data || typeof data !== 'object') return t('common.none');
|
|
56
|
+
const parts = Object.entries(data)
|
|
57
|
+
.filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value))
|
|
58
|
+
.slice(0, 3)
|
|
59
|
+
.map(([key, value]) => `${key}: ${value}`);
|
|
60
|
+
return parts.length ? parts.join(', ') : t('common.none');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every column a views table can show, in default order. The ones marked
|
|
65
|
+
* `hidden` are there to be chosen; `sort` names the API sort key, used when
|
|
66
|
+
* the server offers it.
|
|
67
|
+
*/
|
|
68
|
+
const VIEW_COLUMNS = [
|
|
69
|
+
{ id: 'time', width: 132, sort: 'timestamp', cell: (view) => when(view.timestamp) },
|
|
70
|
+
{ id: 'deleted', width: 136, sort: 'deletedAt', trashOnly: true, cell: (view) => when(view.deletedAt) },
|
|
71
|
+
{ id: 'app', width: 136, allAppsOnly: true, cell: (view) => one(view.appId, 'mono') },
|
|
72
|
+
{
|
|
73
|
+
id: 'page', width: 260, minWidth: 200, grow: true, sort: 'page',
|
|
74
|
+
cell: (view) => two(orNone(view.pagePath), view.hostname ? `${view.hostname} · ${orNone(view.pageTitle)}` : orNone(view.pageTitle)),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'source', width: 144, sort: 'source',
|
|
78
|
+
cell: (view) => two(
|
|
79
|
+
view.sourceType ? tOr(`sources.${view.sourceType}`, view.sourceType) : t('common.none'),
|
|
80
|
+
orNone(view.utmCampaign ?? view.referrerDomain),
|
|
81
|
+
),
|
|
82
|
+
},
|
|
83
|
+
{ id: 'status', width: 150, minWidth: 110 },
|
|
84
|
+
{
|
|
85
|
+
id: 'event', width: 130, sort: 'eventType',
|
|
86
|
+
cell: (view) => two(view.eventType ? tOr(`eventTypes.${view.eventType}`, view.eventType) : t('common.none'), eventSummary(view.eventData)),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'country', width: 140, sort: 'country',
|
|
90
|
+
cell: (view) => two(view.country ? regionName(view.country) : t('common.none'), orNone([view.city, view.region].filter(Boolean).join(', '))),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'device', width: 104, sort: 'deviceSize',
|
|
94
|
+
cell: (view) => two(
|
|
95
|
+
view.deviceSize ? tOr(`deviceSizes.${view.deviceSize}`, view.deviceSize) : t('common.none'),
|
|
96
|
+
view.deviceType ? tOr(`deviceTypes.${view.deviceType}`, view.deviceType) : t('common.none'),
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
{ id: 'client', width: 124, sort: 'browser', cell: (view) => two(orNone(view.browser), orNone(view.os)) },
|
|
100
|
+
{
|
|
101
|
+
id: 'engagement', width: 130, sort: 'engagedMs',
|
|
102
|
+
cell: (view) => two(
|
|
103
|
+
formatDuration(view.engagedMs),
|
|
104
|
+
view.scrollDepth === null || view.scrollDepth === undefined ? t('common.none') : t('details.scrolled', { percent: view.scrollDepth }),
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'campaign', width: 180, sort: 'campaign',
|
|
109
|
+
cell: (view) => two(orNone(view.utmCampaign), orNone([view.utmSource, view.utmMedium, view.utmTerm, view.utmContent].filter(Boolean).join(' · '))),
|
|
110
|
+
},
|
|
111
|
+
{ id: 'title', width: 220, hidden: true, sort: 'title', cell: (view) => one(orNone(view.pageTitle)) },
|
|
112
|
+
{ id: 'site', width: 180, hidden: true, sort: 'hostname', cell: (view) => one(orNone(view.hostname)) },
|
|
113
|
+
{ id: 'referrer', width: 240, hidden: true, sort: 'referrer', cell: (view) => two(orNone(view.referrerDomain), orNone(view.referrer)) },
|
|
114
|
+
{ id: 'language', width: 150, hidden: true, sort: 'language', cell: (view) => one(valueLabel('language', view.language)) },
|
|
115
|
+
{ id: 'browser', width: 140, hidden: true, sort: 'browser', cell: (view) => two(orNone(view.browser), orNone(view.browserVersion)) },
|
|
116
|
+
{ id: 'system', width: 140, hidden: true, sort: 'os', cell: (view) => two(orNone(view.os), orNone(view.osVersion)) },
|
|
117
|
+
{ id: 'ip', width: 140, hidden: true, cell: (view) => one(orNone(view.maskedIp), 'mono') },
|
|
118
|
+
{ id: 'session', width: 160, hidden: true, cell: (view) => one(orNone(view.sessionId), 'mono') },
|
|
119
|
+
{ id: 'viewId', width: 130, hidden: true, cell: (view) => one(view.id, 'mono') },
|
|
120
|
+
{ id: 'note', width: 220, hidden: true, cell: (view) => one(orNone(view.note)) },
|
|
121
|
+
{ id: 'modified', width: 136, hidden: true, sort: 'modifiedAt', cell: (view) => when(view.adminModifiedAt) },
|
|
50
122
|
];
|
|
51
|
-
/** Plus the selection column. */
|
|
52
|
-
const COLUMN_COUNT = COLUMNS.length + 1;
|
|
53
123
|
|
|
54
124
|
/**
|
|
55
125
|
* @param {{ mode: string, meta: object, context: { apps: () => object[], appId: () => string,
|
|
56
|
-
* setAppId: (id: string) => void, refreshApps: () => Promise<void>, reportError: (error: unknown) => void
|
|
126
|
+
* setAppId: (id: string) => void, refreshApps: () => Promise<void>, reportError: (error: unknown) => void,
|
|
127
|
+
* viewsChanged: () => void } }} deps
|
|
57
128
|
*/
|
|
58
129
|
export function createViewsPanel({ mode, meta, context }) {
|
|
59
130
|
const isTrash = mode === PANEL_MODE.TRASH;
|
|
@@ -62,7 +133,9 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
62
133
|
modified: MODIFIED_FILTER.ANY,
|
|
63
134
|
range: RANGE.ALL,
|
|
64
135
|
eventType: '',
|
|
65
|
-
knownEventTypes:
|
|
136
|
+
knownEventTypes: [],
|
|
137
|
+
/** Breakdown values carried over from the Overview, such as one country. */
|
|
138
|
+
where: {},
|
|
66
139
|
search: '',
|
|
67
140
|
sort: isTrash ? 'deletedAt' : 'timestamp',
|
|
68
141
|
order: SORT_ORDER.DESC,
|
|
@@ -80,98 +153,20 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
80
153
|
return state.showDeleted ? VIEW_STATUS.ALL : VIEW_STATUS.ACTIVE;
|
|
81
154
|
};
|
|
82
155
|
|
|
83
|
-
/** The filters the
|
|
156
|
+
/** The filters of the listing, the same the Overview's analysis takes. */
|
|
84
157
|
const filters = () => ({
|
|
85
158
|
status: status(),
|
|
86
159
|
modified: state.modified,
|
|
87
160
|
range: state.range,
|
|
88
161
|
eventType: state.eventType,
|
|
162
|
+
where: state.where,
|
|
89
163
|
search: state.search,
|
|
90
164
|
});
|
|
91
165
|
|
|
92
166
|
// ---- Toolbar -----------------------------------------------------------
|
|
93
167
|
|
|
94
|
-
// One tab per app (each app is its own table). Shared by the Views and
|
|
95
|
-
// Trash panels: switching here switches both. ARIA tabs pattern with
|
|
96
|
-
// automatic activation: arrow keys, Home and End move and select.
|
|
97
168
|
const tableId = uniqueId('views-table');
|
|
98
|
-
const appTabs =
|
|
99
|
-
className: 'app-tabs',
|
|
100
|
-
attrs: { role: 'tablist', 'aria-label': t('appTabs.label') },
|
|
101
|
-
});
|
|
102
|
-
let focusAppOnRender = false;
|
|
103
|
-
|
|
104
|
-
function selectApp(appId, { focus = false } = {}) {
|
|
105
|
-
focusAppOnRender = focus;
|
|
106
|
-
if (appId === context.appId()) {
|
|
107
|
-
renderAppTabs();
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
context.setAppId(appId);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Every app's tab, led by one for all of them together. */
|
|
114
|
-
function tabEntries() {
|
|
115
|
-
const apps = context.apps();
|
|
116
|
-
const sum = (key) => apps.reduce((total, app) => total + app[key], 0);
|
|
117
|
-
return [
|
|
118
|
-
{
|
|
119
|
-
appId: ALL_APPS,
|
|
120
|
-
label: t('appTabs.all'),
|
|
121
|
-
active: sum('active'),
|
|
122
|
-
deleted: sum('deleted'),
|
|
123
|
-
modified: sum('modified'),
|
|
124
|
-
available: true,
|
|
125
|
-
},
|
|
126
|
-
...apps.map((app) => ({ ...app, label: app.appId })),
|
|
127
|
-
];
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function renderAppTabs() {
|
|
131
|
-
const current = context.appId();
|
|
132
|
-
replaceChildren(appTabs, tabEntries().map((app) => {
|
|
133
|
-
const selected = app.appId === current;
|
|
134
|
-
const count = isTrash ? app.deleted : app.active;
|
|
135
|
-
const countText = t(isTrash ? 'appTabs.deletedCount' : 'appTabs.activeCount', { count });
|
|
136
|
-
return el('button', {
|
|
137
|
-
className: `app-tab${selected ? ' active' : ''}${app.available ? '' : ' unavailable'}`,
|
|
138
|
-
attrs: {
|
|
139
|
-
type: 'button',
|
|
140
|
-
role: 'tab',
|
|
141
|
-
'aria-selected': String(selected),
|
|
142
|
-
'aria-controls': tableId,
|
|
143
|
-
'aria-label': t('appTabs.tabName', { app: app.label, count: countText }),
|
|
144
|
-
tabindex: selected ? '0' : '-1',
|
|
145
|
-
title: app.available ? null : t('appTabs.unavailable', { app: app.appId }),
|
|
146
|
-
},
|
|
147
|
-
dataset: { appId: app.appId },
|
|
148
|
-
on: { click: () => selectApp(app.appId) },
|
|
149
|
-
}, [
|
|
150
|
-
el('span', { className: 'prompt-char', text: '❯', attrs: { 'aria-hidden': 'true' } }),
|
|
151
|
-
el('span', { className: 'app-tab-name', text: app.label }),
|
|
152
|
-
el('span', { className: 'app-tab-count', text: formatNumber(count), attrs: { 'aria-hidden': 'true' } }),
|
|
153
|
-
]);
|
|
154
|
-
}));
|
|
155
|
-
if (focusAppOnRender) {
|
|
156
|
-
appTabs.querySelector('[aria-selected="true"]')?.focus();
|
|
157
|
-
focusAppOnRender = false;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
appTabs.addEventListener('keydown', (event) => {
|
|
162
|
-
const ids = tabEntries().map((app) => app.appId);
|
|
163
|
-
if (ids.length === 0) return;
|
|
164
|
-
const index = Math.max(0, ids.indexOf(context.appId()));
|
|
165
|
-
const moves = {
|
|
166
|
-
[KEY.ARROW_RIGHT]: (index + 1) % ids.length,
|
|
167
|
-
[KEY.ARROW_LEFT]: (index - 1 + ids.length) % ids.length,
|
|
168
|
-
[KEY.HOME]: 0,
|
|
169
|
-
[KEY.END]: ids.length - 1,
|
|
170
|
-
};
|
|
171
|
-
if (!(event.key in moves)) return;
|
|
172
|
-
event.preventDefault();
|
|
173
|
-
selectApp(ids[moves[event.key]], { focus: true });
|
|
174
|
-
});
|
|
169
|
+
const appTabs = createAppTabs({ context, controls: tableId, trash: isTrash });
|
|
175
170
|
|
|
176
171
|
const searchInput = el('input', {
|
|
177
172
|
className: 'input search-input',
|
|
@@ -218,7 +213,7 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
218
213
|
|
|
219
214
|
const eventTypeOptions = () => [
|
|
220
215
|
{ value: '', label: t('toolbar.allEventTypes') },
|
|
221
|
-
...
|
|
216
|
+
...state.knownEventTypes.map((type) => ({ value: type, label: tOr(`eventTypes.${type}`, type) })),
|
|
222
217
|
];
|
|
223
218
|
const eventTypePicker = createListbox({
|
|
224
219
|
label: t('toolbar.eventType'),
|
|
@@ -242,11 +237,33 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
242
237
|
},
|
|
243
238
|
});
|
|
244
239
|
|
|
240
|
+
// Filters carried over from the Overview, each removable on its own.
|
|
241
|
+
const chips = el('div', { className: 'filter-chips', attrs: { 'aria-live': 'polite' } });
|
|
242
|
+
function renderChips() {
|
|
243
|
+
replaceChildren(chips, Object.entries(state.where).map(([dim, value]) => {
|
|
244
|
+
const label = `${t(`overview.dims.${dim}`)}: ${valueLabel(dim, value)}`;
|
|
245
|
+
return el('button', {
|
|
246
|
+
className: 'chip',
|
|
247
|
+
attrs: { type: 'button', 'aria-label': t('overview.removeFilter', { filter: label }), title: t('overview.removeFilter', { filter: label }) },
|
|
248
|
+
dataset: { dim },
|
|
249
|
+
on: { click: () => {
|
|
250
|
+
state.where = Object.fromEntries(Object.entries(state.where).filter(([key]) => key !== dim));
|
|
251
|
+
resetAndLoad();
|
|
252
|
+
} },
|
|
253
|
+
}, [icon('filter'), el('span', { className: 'chip-text', text: label }), icon('close')]);
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Filled once the table exists: its "Columns" button sits with the other
|
|
258
|
+
// controls that change only how the rows are shown.
|
|
259
|
+
const columnsSlot = el('span', { className: 'toolbar-slot' });
|
|
260
|
+
|
|
245
261
|
const toolbar = el('div', { className: 'toolbar' }, [
|
|
246
262
|
el('div', { className: 'toolbar-group' }, [
|
|
247
263
|
rangeFilter.element,
|
|
248
264
|
isTrash ? null : eventTypePicker.element,
|
|
249
265
|
searchInput,
|
|
266
|
+
chips,
|
|
250
267
|
]),
|
|
251
268
|
el('div', { className: 'toolbar-group' }, [
|
|
252
269
|
modifiedFilter.element,
|
|
@@ -256,6 +273,7 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
256
273
|
el('span', { className: 'switch-label', text: t('toolbar.showDeleted') }),
|
|
257
274
|
]),
|
|
258
275
|
pageSizePicker.element,
|
|
276
|
+
columnsSlot,
|
|
259
277
|
]),
|
|
260
278
|
]);
|
|
261
279
|
|
|
@@ -272,7 +290,6 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
272
290
|
});
|
|
273
291
|
|
|
274
292
|
const batchBar = el('div', { className: 'batch-bar', attrs: { role: 'region', 'aria-label': t('batch.region') } }, [
|
|
275
|
-
el('span', { className: 'prompt-char', text: '❯', attrs: { 'aria-hidden': 'true' } }),
|
|
276
293
|
batchCount,
|
|
277
294
|
el('div', { className: 'batch-actions' }, [
|
|
278
295
|
isTrash ? null : batchButton('batch.edit', 'secondary', () => editViews(selectedViews())),
|
|
@@ -294,9 +311,34 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
294
311
|
attrs: { type: 'checkbox', 'aria-label': t('table.selectPage') },
|
|
295
312
|
on: { change: () => togglePage(selectAll.checked) },
|
|
296
313
|
});
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
const
|
|
314
|
+
|
|
315
|
+
const allApps = () => context.appId() === ALL_APPS;
|
|
316
|
+
const grid = createDataTable({
|
|
317
|
+
storageKey: mode,
|
|
318
|
+
id: tableId,
|
|
319
|
+
className: 'views-table',
|
|
320
|
+
// The trash leads with when a view was deleted; elsewhere that is one more column to choose.
|
|
321
|
+
columns: VIEW_COLUMNS.map(({ sort, allAppsOnly, trashOnly, cell, ...column }) => ({
|
|
322
|
+
...column,
|
|
323
|
+
label: t(`columns.${column.id}`),
|
|
324
|
+
hidden: trashOnly ? !isTrash : column.hidden,
|
|
325
|
+
sortKey: meta.sortFields.includes(sort) ? sort : undefined,
|
|
326
|
+
available: allAppsOnly ? allApps : undefined,
|
|
327
|
+
unavailableNote: allAppsOnly ? t('table.onlyAllApps') : undefined,
|
|
328
|
+
cell: column.id === 'status' ? statusCell : cell,
|
|
329
|
+
})),
|
|
330
|
+
rowKey: (view) => view.id,
|
|
331
|
+
rowAttrs: (view) => ({ className: isDeleted(view) ? 'row-deleted' : '', dataset: { viewId: view.id } }),
|
|
332
|
+
select: { header: () => selectAll, cell: selectCell },
|
|
333
|
+
actions: { label: t('columns.actions'), width: ACTIONS_WIDTH, cell: actionsCell },
|
|
334
|
+
sort: () => ({ key: state.sort, order: state.order }),
|
|
335
|
+
onSort,
|
|
336
|
+
empty: () => (isTrash ? t('table.emptyTrash') : t('table.empty')),
|
|
337
|
+
});
|
|
338
|
+
const { table } = grid;
|
|
339
|
+
grid.onRender(renderSelection);
|
|
340
|
+
columnsSlot.append(grid.chooserButton);
|
|
341
|
+
|
|
300
342
|
const pager = createPager((page) => {
|
|
301
343
|
state.page = page;
|
|
302
344
|
load();
|
|
@@ -304,33 +346,20 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
304
346
|
|
|
305
347
|
const retention = isTrash ? el('p', { className: 'notice' }) : null;
|
|
306
348
|
|
|
307
|
-
// Insights live on the Views tab; the trash is for recovery, not analysis.
|
|
308
|
-
const insights = isTrash ? null : createInsightsPanel({
|
|
309
|
-
reportError: context.reportError,
|
|
310
|
-
onData: (data) => {
|
|
311
|
-
// The server lists every event type these apps hold, whatever the
|
|
312
|
-
// other filters say, so choosing one never hides the others. The
|
|
313
|
-
// current choice stays on offer even if its last view was edited.
|
|
314
|
-
const types = new Set(data.eventTypes);
|
|
315
|
-
if (state.eventType) types.add(state.eventType);
|
|
316
|
-
const known = state.knownEventTypes;
|
|
317
|
-
if (types.size === known.size && [...types].every((type) => known.has(type))) return;
|
|
318
|
-
state.knownEventTypes = types;
|
|
319
|
-
eventTypePicker.setOptions(eventTypeOptions(), state.eventType);
|
|
320
|
-
},
|
|
321
|
-
});
|
|
322
|
-
|
|
323
349
|
const element = el('section', {
|
|
324
350
|
className: 'panel',
|
|
325
351
|
attrs: { 'aria-label': isTrash ? t('tabs.trash') : t('tabs.views') },
|
|
326
352
|
}, [
|
|
327
|
-
|
|
353
|
+
el('header', { className: 'panel-header' }, [
|
|
354
|
+
el('h2', { className: 'panel-title', text: isTrash ? t('tabs.trash') : t('tabs.views') }),
|
|
355
|
+
el('p', { className: 'panel-intro', text: isTrash ? t('trash.intro') : t('views.intro') }),
|
|
356
|
+
]),
|
|
357
|
+
appTabs.element,
|
|
328
358
|
toolbar,
|
|
329
359
|
retention,
|
|
330
|
-
insights?.element,
|
|
331
360
|
summary,
|
|
332
361
|
batchBar,
|
|
333
|
-
|
|
362
|
+
grid.element,
|
|
334
363
|
pager.element,
|
|
335
364
|
]);
|
|
336
365
|
|
|
@@ -380,35 +409,21 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
380
409
|
load();
|
|
381
410
|
}
|
|
382
411
|
|
|
383
|
-
|
|
384
|
-
replaceChildren(thead, [el('tr', {}, [
|
|
385
|
-
el('th', { className: 'col-select', attrs: { scope: 'col' } }, [selectAll]),
|
|
386
|
-
...COLUMNS.map((column) => headerCell({
|
|
387
|
-
label: t(`columns.${column.key}`),
|
|
388
|
-
sortKey: column.sort,
|
|
389
|
-
sort: state.sort,
|
|
390
|
-
order: state.order,
|
|
391
|
-
onSort,
|
|
392
|
-
className: column.className,
|
|
393
|
-
})),
|
|
394
|
-
])]);
|
|
395
|
-
}
|
|
412
|
+
const isDeleted = (view) => view.deletedAt !== null && view.deletedAt !== undefined;
|
|
396
413
|
|
|
397
414
|
function badge(key, className, title) {
|
|
398
415
|
return el('span', { className: `badge ${className}`, text: t(key), attrs: title ? { title } : {} });
|
|
399
416
|
}
|
|
400
417
|
|
|
401
|
-
function actionButton(
|
|
418
|
+
function actionButton(iconName, labelKey, handler, variant = '') {
|
|
402
419
|
return el('button', {
|
|
403
420
|
className: `icon-btn row-action ${variant}`.trim(),
|
|
404
|
-
text: icon,
|
|
405
421
|
attrs: { type: 'button', 'aria-label': t(labelKey), title: t(labelKey) },
|
|
406
422
|
on: { click: handler },
|
|
407
|
-
});
|
|
423
|
+
}, [icon(iconName)]);
|
|
408
424
|
}
|
|
409
425
|
|
|
410
|
-
function
|
|
411
|
-
const deleted = view.deletedAt !== null && view.deletedAt !== undefined;
|
|
426
|
+
function selectCell(view) {
|
|
412
427
|
const checkbox = el('input', {
|
|
413
428
|
attrs: {
|
|
414
429
|
type: 'checkbox',
|
|
@@ -417,50 +432,28 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
417
432
|
on: { change: (event) => toggleRow(view, event.currentTarget.checked) },
|
|
418
433
|
});
|
|
419
434
|
checkbox.checked = state.selection.has(view.id);
|
|
435
|
+
return checkbox;
|
|
436
|
+
}
|
|
420
437
|
|
|
421
|
-
|
|
422
|
-
|
|
438
|
+
function statusCell(view) {
|
|
439
|
+
return [el('div', { className: 'badges' }, [
|
|
440
|
+
isDeleted(view) ? badge('status.deleted', 'badge-danger', formatDateTime(view.deletedAt)) : null,
|
|
423
441
|
view.adminModifiedAt ? badge('status.modified', 'badge-warning', formatDateTime(view.adminModifiedAt)) : null,
|
|
424
442
|
view.note ? badge('status.note', 'badge-info', view.note) : null,
|
|
425
443
|
view.isUnique ? null : badge('status.repeat', 'badge-muted'),
|
|
426
|
-
];
|
|
444
|
+
])];
|
|
445
|
+
}
|
|
427
446
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
actionButton('
|
|
447
|
+
function actionsCell(view) {
|
|
448
|
+
const deleted = isDeleted(view);
|
|
449
|
+
return el('div', { className: 'row-actions' }, [
|
|
450
|
+
actionButton('info', 'rowActions.details', () => openDetails(view)),
|
|
451
|
+
deleted ? null : actionButton('edit', 'rowActions.edit', () => editViews([view])),
|
|
452
|
+
actionButton('note', 'rowActions.note', () => noteViews([view])),
|
|
432
453
|
deleted
|
|
433
|
-
? actionButton('
|
|
434
|
-
: actionButton('
|
|
435
|
-
deleted && isTrash ? actionButton('
|
|
436
|
-
];
|
|
437
|
-
|
|
438
|
-
const source = view.sourceType ? tOr(`sources.${view.sourceType}`, view.sourceType) : t('common.none');
|
|
439
|
-
|
|
440
|
-
return el('tr', { className: deleted ? 'row-deleted' : '', dataset: { viewId: view.id } }, [
|
|
441
|
-
el('td', { className: 'col-select' }, [checkbox]),
|
|
442
|
-
timeCell(view.timestamp),
|
|
443
|
-
el('td', { className: 'col-app' }, [clampText(view.appId, { className: 'mono' })]),
|
|
444
|
-
el('td', { className: 'col-page' }, [
|
|
445
|
-
clampText(orNone(view.pagePath), { className: 'cell-primary' }),
|
|
446
|
-
clampText(orNone(view.pageTitle), { lines: CLAMP_LINES.SINGLE, className: 'cell-secondary' }),
|
|
447
|
-
]),
|
|
448
|
-
el('td', { className: 'col-source' }, [
|
|
449
|
-
clampText(source, { className: 'cell-primary' }),
|
|
450
|
-
clampText(orNone(view.referrerDomain), { className: 'cell-secondary' }),
|
|
451
|
-
]),
|
|
452
|
-
el('td', { className: 'col-device' }, [
|
|
453
|
-
clampText(view.deviceSize ? tOr(`deviceSizes.${view.deviceSize}`, view.deviceSize) : t('common.none'), { className: 'cell-primary' }),
|
|
454
|
-
clampText(orNone(view.deviceType), { className: 'cell-secondary' }),
|
|
455
|
-
]),
|
|
456
|
-
el('td', { className: 'col-country' }, [clampText(orNone(view.country))]),
|
|
457
|
-
el('td', { className: 'col-client' }, [
|
|
458
|
-
clampText(orNone(view.browser), { className: 'cell-primary' }),
|
|
459
|
-
clampText(orNone(view.os), { className: 'cell-secondary' }),
|
|
460
|
-
]),
|
|
461
|
-
el('td', { className: 'col-event' }, [clampText(orNone(view.eventType))]),
|
|
462
|
-
el('td', { className: 'col-status' }, [el('div', { className: 'badges' }, badges)]),
|
|
463
|
-
el('td', { className: 'col-actions' }, [el('div', { className: 'row-actions' }, actions)]),
|
|
454
|
+
? actionButton('restore', 'rowActions.restore', () => restoreViews([view]))
|
|
455
|
+
: actionButton('trash', 'rowActions.delete', () => deleteViews([view]), 'danger'),
|
|
456
|
+
deleted && isTrash ? actionButton('purge', 'rowActions.purge', () => purgeViews([view]), 'danger') : null,
|
|
464
457
|
]);
|
|
465
458
|
}
|
|
466
459
|
|
|
@@ -475,7 +468,7 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
475
468
|
selectAll.indeterminate = selectedOnPage > 0 && selectedOnPage < pageIds.length;
|
|
476
469
|
selectAll.disabled = pageIds.length === 0;
|
|
477
470
|
|
|
478
|
-
for (const row of
|
|
471
|
+
for (const row of table.querySelectorAll('tbody tr[data-view-id]')) {
|
|
479
472
|
const checkbox = row.querySelector('.col-select input');
|
|
480
473
|
if (checkbox) checkbox.checked = state.selection.has(row.dataset.viewId);
|
|
481
474
|
}
|
|
@@ -486,20 +479,14 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
486
479
|
|
|
487
480
|
/** Rebuild the table from state. After a sort, focus returns to its header. */
|
|
488
481
|
function render() {
|
|
489
|
-
|
|
490
|
-
if (state.rows.length === 0) {
|
|
491
|
-
replaceChildren(tbody, [messageRow(COLUMN_COUNT, isTrash ? t('table.emptyTrash') : t('table.empty'))]);
|
|
492
|
-
} else {
|
|
493
|
-
replaceChildren(tbody, state.rows.map(renderRow));
|
|
494
|
-
}
|
|
495
|
-
renderSelection();
|
|
482
|
+
grid.setRows(state.rows);
|
|
496
483
|
pager.update(state.page, state.pageSize, state.total);
|
|
497
484
|
if (state.focusSort) {
|
|
498
|
-
|
|
485
|
+
grid.sortButton(state.focusSort)?.focus();
|
|
499
486
|
state.focusSort = null;
|
|
500
487
|
}
|
|
501
488
|
|
|
502
|
-
const app =
|
|
489
|
+
const app = appEntries(context.apps()).find((entry) => entry.appId === context.appId());
|
|
503
490
|
summary.textContent = app
|
|
504
491
|
? t('views.summary', {
|
|
505
492
|
active: formatNumber(app.active),
|
|
@@ -535,8 +522,6 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
535
522
|
if (seq !== state.requestSeq) return;
|
|
536
523
|
state.rows = result.views;
|
|
537
524
|
state.total = result.total;
|
|
538
|
-
// Rows name their app only under All apps; one app's tab already does.
|
|
539
|
-
table.classList.toggle('has-app', appId === ALL_APPS);
|
|
540
525
|
if (state.rows.length === 0 && state.page > 1 && state.total > 0) {
|
|
541
526
|
state.page = Math.max(1, Math.ceil(state.total / state.pageSize));
|
|
542
527
|
await load();
|
|
@@ -550,16 +535,30 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
550
535
|
}
|
|
551
536
|
}
|
|
552
537
|
|
|
553
|
-
/**
|
|
554
|
-
|
|
555
|
-
|
|
538
|
+
/**
|
|
539
|
+
* Every event type the app holds in this status, whatever else is
|
|
540
|
+
* filtered, so choosing one never hides the others. The current choice
|
|
541
|
+
* stays on offer even if its last view was edited.
|
|
542
|
+
*/
|
|
543
|
+
async function loadEventTypes() {
|
|
544
|
+
const appId = context.appId();
|
|
545
|
+
if (isTrash || !appId) return;
|
|
546
|
+
try {
|
|
547
|
+
const query = { status: status() };
|
|
548
|
+
const { eventTypes } = appId === ALL_APPS ? await api.eventTypesAll(query) : await api.eventTypes(appId, query);
|
|
549
|
+
if (appId !== context.appId()) return;
|
|
550
|
+
state.knownEventTypes = [...new Set([...eventTypes, ...(state.eventType ? [state.eventType] : [])])].sort();
|
|
551
|
+
eventTypePicker.setOptions(eventTypeOptions(), state.eventType);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
context.reportError(error);
|
|
554
|
+
}
|
|
556
555
|
}
|
|
557
556
|
|
|
558
557
|
function resetAndLoad() {
|
|
559
558
|
state.page = 1;
|
|
560
559
|
state.selection.clear();
|
|
560
|
+
renderChips();
|
|
561
561
|
load();
|
|
562
|
-
loadInsights();
|
|
563
562
|
}
|
|
564
563
|
|
|
565
564
|
// ---- Operations --------------------------------------------------------
|
|
@@ -604,10 +603,11 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
604
603
|
}
|
|
605
604
|
if (failure) context.reportError(failure);
|
|
606
605
|
if (result.affected > 0 || !failure) {
|
|
606
|
+
context.viewsChanged();
|
|
607
607
|
try {
|
|
608
608
|
await context.refreshApps();
|
|
609
609
|
await load();
|
|
610
|
-
|
|
610
|
+
loadEventTypes();
|
|
611
611
|
} catch (error) {
|
|
612
612
|
context.reportError(error);
|
|
613
613
|
}
|
|
@@ -668,18 +668,84 @@ export function createViewsPanel({ mode, meta, context }) {
|
|
|
668
668
|
|
|
669
669
|
render();
|
|
670
670
|
|
|
671
|
+
let visible = false;
|
|
672
|
+
let stale = true;
|
|
673
|
+
|
|
671
674
|
return {
|
|
672
675
|
element,
|
|
673
676
|
/** The app list or selected app changed. */
|
|
674
677
|
syncApps() {
|
|
675
|
-
|
|
678
|
+
appTabs.render();
|
|
676
679
|
},
|
|
677
|
-
/**
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
680
|
+
/** The app changed: breakdown values of one app mean nothing in another. */
|
|
681
|
+
appChanged() {
|
|
682
|
+
state.where = {};
|
|
683
|
+
state.page = 1;
|
|
684
|
+
state.selection.clear();
|
|
685
|
+
stale = true;
|
|
686
|
+
if (visible) {
|
|
687
|
+
resetAndLoad();
|
|
688
|
+
loadEventTypes();
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
/** Shown: start over after an app change, otherwise refresh the page, keeping the selection. */
|
|
692
|
+
show() {
|
|
693
|
+
visible = true;
|
|
694
|
+
if (stale) {
|
|
695
|
+
stale = false;
|
|
696
|
+
resetAndLoad();
|
|
697
|
+
} else {
|
|
698
|
+
load();
|
|
699
|
+
}
|
|
700
|
+
loadEventTypes();
|
|
701
|
+
},
|
|
702
|
+
hide() {
|
|
703
|
+
visible = false;
|
|
704
|
+
},
|
|
705
|
+
/** Rows changed elsewhere (in the other of Views and Trash). */
|
|
706
|
+
invalidate() {
|
|
707
|
+
if (visible) load();
|
|
708
|
+
},
|
|
709
|
+
/**
|
|
710
|
+
* Show exactly the rows an Overview describes: its range, event type,
|
|
711
|
+
* and breakdown values. Search and the admin-changes filter start over.
|
|
712
|
+
* @param {{ range: string, eventType: string, where: Record<string, string|null> }} next
|
|
713
|
+
*/
|
|
714
|
+
applyFilters({ range, eventType, where }) {
|
|
715
|
+
state.range = range;
|
|
716
|
+
state.eventType = eventType;
|
|
717
|
+
state.where = { ...where };
|
|
718
|
+
state.search = '';
|
|
719
|
+
state.modified = MODIFIED_FILTER.ANY;
|
|
720
|
+
searchInput.value = '';
|
|
721
|
+
rangeFilter.setValue(range);
|
|
722
|
+
modifiedFilter.setValue(MODIFIED_FILTER.ANY);
|
|
723
|
+
if (eventType && !state.knownEventTypes.includes(eventType)) {
|
|
724
|
+
state.knownEventTypes = [...state.knownEventTypes, eventType].sort();
|
|
725
|
+
}
|
|
726
|
+
eventTypePicker.setOptions(eventTypeOptions(), eventType);
|
|
727
|
+
stale = true;
|
|
728
|
+
},
|
|
729
|
+
/**
|
|
730
|
+
* Show one view by its ID, wherever it is: live or in the trash, in
|
|
731
|
+
* any range. Every other filter starts over.
|
|
732
|
+
* @param {string} viewId
|
|
733
|
+
*/
|
|
734
|
+
findView(viewId) {
|
|
735
|
+
state.range = RANGE.ALL;
|
|
736
|
+
state.eventType = '';
|
|
737
|
+
state.where = {};
|
|
738
|
+
state.modified = MODIFIED_FILTER.ANY;
|
|
739
|
+
state.search = viewId;
|
|
740
|
+
searchInput.value = viewId;
|
|
741
|
+
if (!isTrash) {
|
|
742
|
+
state.showDeleted = true;
|
|
743
|
+
showDeletedToggle.checked = true;
|
|
744
|
+
}
|
|
745
|
+
rangeFilter.setValue(RANGE.ALL);
|
|
746
|
+
modifiedFilter.setValue(MODIFIED_FILTER.ANY);
|
|
747
|
+
eventTypePicker.setOptions(eventTypeOptions(), '');
|
|
748
|
+
stale = true;
|
|
683
749
|
},
|
|
684
750
|
};
|
|
685
751
|
}
|