Web: add notification history pop-over

Add a notification pop-over, which can be opened by clicking the footer
bar.
This commit is contained in:
Sybren A. Stüvel 2022-05-19 12:57:00 +02:00
parent bc355d68ab
commit 40bed3db5e
5 changed files with 151 additions and 17 deletions

View File

@ -67,7 +67,7 @@ body {
color-scheme: dark;
font-family: var(--font-family-body);
font-size: var(--font-size-base);
height: 100vh;
height: calc(100vh);
margin: 0;
padding: 0;
}
@ -215,6 +215,8 @@ footer {
font-size: var(--font-size-sm);
grid-area: footer;
padding: var(--spacer-sm);
background-color: var(--color-background-column);
}
.btn-bar {
@ -360,7 +362,7 @@ ul.status-filter-bar .status-filter-indicator .indicator {
background-color: transparent;
}
.tabulator-row {
.job-list .tabulator-row, .tasks-list .tabulator-row {
cursor: pointer;
}
@ -371,3 +373,36 @@ ul.status-filter-bar .status-filter-indicator .indicator {
.tabulator-row.active-row.tabulator-row-even {
background-color: var(--table-color-background-row-active-even);
}
footer.window-footer {
cursor: pointer;
}
section.footer-popup {
position: absolute;
bottom: var(--grid-gap);
left: var(--grid-gap);
right: var(--grid-gap);
height: 20vh;
z-index: 42;
padding: 0.2rem 0.5rem;
background-color: var(--color-background-column);
border-radius: 0.3rem;
border: thin solid var(--color-border);
box-shadow: 0 0 2rem black;
}
section.footer-popup header {
display: flex;
}
section.footer-popup header h3 {
margin: 0 auto 0 0;
}
section.footer-popup button {
float: right;
}

View File

@ -0,0 +1,66 @@
<script setup>
import { onMounted } from 'vue'
import { TabulatorFull as Tabulator } from 'tabulator-tables';
import { useNotifs } from '@/stores/notifications'
import * as datetime from "@/datetime";
const notifs = useNotifs();
const emit = defineEmits(['clickClose'])
const tabOptions = {
columns: [
{
title: 'Time', field: 'time',
sorter: 'alphanum', sorterParams: { alignEmptyValues: "top" },
formatter(cell) {
const cellValue = cell.getData().time;
return datetime.shortened(cellValue);
},
widthGrow: 1,
resizable: true,
},
{
title: 'Message',
field: 'msg',
sorter: 'string',
widthGrow: 100,
resizable: true,
},
],
initialSort: [
{ column: "time", dir: "asc" },
],
layout: "fitDataStretch",
resizableColumnFit: true,
height: "calc(20vh - 3rem)", // Must be set in order for the virtual DOM to function correctly.
data: notifs.history,
selectable: false,
};
let tabulator = null;
onMounted(() => {
tabulator = new Tabulator('#notification_list', tabOptions);
tabulator.on("tableBuilt", _scrollToBottom);
tabulator.on("tableBuilt", _subscribeToPinia);
});
function _scrollToBottom() {
tabulator.scrollToRow(notifs.lastID, "bottom", false);
}
function _subscribeToPinia() {
notifs.$subscribe(() => {
tabulator.setData(notifs.history)
.then(_scrollToBottom)
})
}
</script>
<template>
<section class="footer-popup">
<header>
<h3 class="sub-title">Notifications</h3>
<button class='close' @click="emit('clickClose')">X</button>
</header>
<div id="notification_list"></div>
</section>
</template>

View File

@ -5,17 +5,25 @@ const relativeTimeDefaultOptions = {
format: DateTime.DATE_MED_WITH_WEEKDAY,
}
/**
* Convert the given timestamp to a Luxon time object.
*
* @param {Date | string} timestamp either a Date object or an ISO time string.
* @returns Luxon time object.
*/
function parseTimestamp(timestamp) {
if (timestamp instanceof Date) {
return DateTime.fromJSDate(timestamp);
}
return DateTime.fromISO(timestamp);
}
// relativeTime parses the timestamp (can be ISO-formatted string or JS Date
// object) and returns it in string form. The returned string is either "xxx
// time ago" if it's a relatively short time ago, or the formatted absolute time
// otherwise.
export function relativeTime(timestamp, options) {
let parsedTimestamp = null;
if (timestamp instanceof Date) {
parsedTimestamp = DateTime.fromJSDate(timestamp);
} else {
parsedTimestamp = DateTime.fromISO(timestamp);
}
const parsedTimestamp = parseTimestamp(timestamp);
if (!options) options = relativeTimeDefaultOptions;
@ -25,3 +33,12 @@ export function relativeTime(timestamp, options) {
return parsedTimestamp.toLocaleString(options.format);
return parsedTimestamp.toRelative({style: "narrow"});
}
export function shortened(timestamp) {
const parsedTimestamp = parseTimestamp(timestamp);
const now = DateTime.local();
const ageInHours = now.diff(parsedTimestamp).as('hours');
if (ageInHours < 24)
return parsedTimestamp.toLocaleString(DateTime.TIME_24_SIMPLE);
return parsedTimestamp.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY);
}

View File

@ -8,12 +8,19 @@ const MESSAGE_HIDE_DELAY_MS = 5000;
*/
export const useNotifs = defineStore('notifications', {
state: () => ({
/** @type {{ msg: string, time: Date }[]} */
/**
* History of notifications.
*
* The 'id' is just for Tabulator to uniquely identify rows, in order to be
* able to scroll to them.
*
* @type {{ id: Number, msg: string, time: Date }[]} */
history: [],
/** @type { msg: string, time: Date } */
/** @type { id: Number, msg: string, time: Date } */
last: "",
hideTimerID: 0,
lastID: 0,
}),
actions: {
/**
@ -21,9 +28,10 @@ export const useNotifs = defineStore('notifications', {
* @param {string} message
*/
add(message) {
const notif = {msg: message, time: new Date()};
const notif = {id: this._generateID(), msg: message, time: new Date()};
this.history.push(notif);
this.last = notif;
console.log("New notification:", plain(notif));
this._prune();
this._restartHideTimer();
},
@ -44,5 +52,8 @@ export const useNotifs = defineStore('notifications', {
last: "",
});
},
_generateID() {
return ++this.lastID;
}
},
})

View File

@ -9,12 +9,13 @@
<div class="col col-3">
<task-details :taskData="tasks.activeTask" />
</div>
<footer>
<notification-bar />
<update-listener ref="updateListener" :websocketURL="websocketURL" :subscribedJob="jobID"
@jobUpdate="onSioJobUpdate" @taskUpdate="onSioTaskUpdate" @message="onChatMessage"
@sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" />
</footer>
<footer class="window-footer" v-if="!showFooterPopup" @click="showFooterPopup = true"><notification-bar /></footer>
<footer-popup v-if="showFooterPopup" ref="footerPopup" @clickClose="showFooterPopup = false" />
<update-listener ref="updateListener" :websocketURL="websocketURL" :subscribedJob="jobID"
@jobUpdate="onSioJobUpdate" @taskUpdate="onSioTaskUpdate" @message="onChatMessage"
@sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" />
</template>
<script>
@ -24,6 +25,7 @@ import { useJobs } from '@/stores/jobs';
import { useTasks } from '@/stores/tasks';
import { apiClient } from '@/stores/api-query-count';
import FooterPopup from '@/components/FooterPopup.vue'
import JobDetails from '@/components/JobDetails.vue'
import JobsTable from '@/components/JobsTable.vue'
import NotificationBar from '@/components/NotificationBar.vue'
@ -35,6 +37,7 @@ export default {
name: 'JobsView',
props: ["jobID", "taskID"], // provided by Vue Router.
components: {
FooterPopup,
JobDetails,
JobsTable,
NotificationBar,
@ -48,9 +51,11 @@ export default {
jobs: useJobs(),
tasks: useTasks(),
showFooterPopup: false,
}),
mounted() {
window.jobsView = this;
window.footerPopup = this.$refs.footerPopup;
// Useful for debugging:
// this.jobs.$subscribe((mutation, state) => {