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; color-scheme: dark;
font-family: var(--font-family-body); font-family: var(--font-family-body);
font-size: var(--font-size-base); font-size: var(--font-size-base);
height: 100vh; height: calc(100vh);
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
@ -215,6 +215,8 @@ footer {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
grid-area: footer; grid-area: footer;
padding: var(--spacer-sm); padding: var(--spacer-sm);
background-color: var(--color-background-column);
} }
.btn-bar { .btn-bar {
@ -360,7 +362,7 @@ ul.status-filter-bar .status-filter-indicator .indicator {
background-color: transparent; background-color: transparent;
} }
.tabulator-row { .job-list .tabulator-row, .tasks-list .tabulator-row {
cursor: pointer; cursor: pointer;
} }
@ -371,3 +373,36 @@ ul.status-filter-bar .status-filter-indicator .indicator {
.tabulator-row.active-row.tabulator-row-even { .tabulator-row.active-row.tabulator-row-even {
background-color: var(--table-color-background-row-active-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, 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 // 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 // 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 // time ago" if it's a relatively short time ago, or the formatted absolute time
// otherwise. // otherwise.
export function relativeTime(timestamp, options) { export function relativeTime(timestamp, options) {
let parsedTimestamp = null; const parsedTimestamp = parseTimestamp(timestamp);
if (timestamp instanceof Date) {
parsedTimestamp = DateTime.fromJSDate(timestamp);
} else {
parsedTimestamp = DateTime.fromISO(timestamp);
}
if (!options) options = relativeTimeDefaultOptions; if (!options) options = relativeTimeDefaultOptions;
@ -25,3 +33,12 @@ export function relativeTime(timestamp, options) {
return parsedTimestamp.toLocaleString(options.format); return parsedTimestamp.toLocaleString(options.format);
return parsedTimestamp.toRelative({style: "narrow"}); 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', { export const useNotifs = defineStore('notifications', {
state: () => ({ 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: [], history: [],
/** @type { msg: string, time: Date } */ /** @type { id: Number, msg: string, time: Date } */
last: "", last: "",
hideTimerID: 0, hideTimerID: 0,
lastID: 0,
}), }),
actions: { actions: {
/** /**
@ -21,9 +28,10 @@ export const useNotifs = defineStore('notifications', {
* @param {string} message * @param {string} message
*/ */
add(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.history.push(notif);
this.last = notif; this.last = notif;
console.log("New notification:", plain(notif));
this._prune(); this._prune();
this._restartHideTimer(); this._restartHideTimer();
}, },
@ -44,5 +52,8 @@ export const useNotifs = defineStore('notifications', {
last: "", last: "",
}); });
}, },
_generateID() {
return ++this.lastID;
}
}, },
}) })

View File

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