Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement change alerts on Manage Queues and Queue Manager pages (#157, #160, #163) #258

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
25ec438
Add first draft of change log; implement in manage.tsx
ssciolla Nov 19, 2020
6ea8a3f
Tweak syntax, import ordering
ssciolla Nov 19, 2020
ca12ebd
Implement change log for meetings in queueManager
ssciolla Nov 19, 2020
4dfd86d
Replace EntityType with type checkers
ssciolla Nov 19, 2020
ee5966a
Use more specific var names with useEntityChanges in manage.tsx
ssciolla Nov 19, 2020
41e7c6a
Modify identifier for meetings; implement detectChanges
ssciolla Nov 23, 2020
2b0fc59
Switch to using ChangeEvents[] instead of ChangeEventMap
ssciolla Nov 24, 2020
bcf6702
Refactor deleteChangeEvent; implement TimedChangeAlert
ssciolla Nov 24, 2020
1e3ce87
Add aria-live property to Alerts
ssciolla Dec 1, 2020
03b94ae
Change location of meeting ChangeLog
ssciolla Dec 9, 2020
a5f546d
Use ChangeLog inside of QueueManager instead of using props.children
ssciolla Dec 9, 2020
2a4b612
Compare values in detectChanges after user object handling; only anno…
ssciolla Dec 9, 2020
d16e771
Reassign falsy values to "None"
ssciolla Dec 9, 2020
6bbbb7c
Add custom check for whether meeting has changed to in progress
ssciolla Dec 9, 2020
c698316
Tweak return value of detectChanges
ssciolla Dec 9, 2020
54f18fc
Fix a few misc. issues
ssciolla Dec 9, 2020
1bd1d20
Use ComparableEntity with generic instead of Base
ssciolla Jan 5, 2021
2beee7b
Remove mention of EventMap
ssciolla Jan 11, 2021
affb03f
Refactor to allow multiple changeMessages, multiple changes; tidy up
ssciolla Jan 19, 2021
c10b9f1
Add missing semicolons and newline
ssciolla Jan 20, 2021
ba5614e
Simplify return types; rename a var
ssciolla Jan 20, 2021
f64b685
Remove stray indentation
ssciolla Jan 20, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/assets/src/changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import xorWith from "lodash.xorwith";
import isEqual from "lodash.isequal";

import { isMeeting, isQueueBase, isUser, Meeting, MeetingStatus, QueueBase } from "./models"

export type ComparableEntity = QueueBase | Meeting;

export interface ChangeEvent {
eventID: number;
text: string;
}

const queueBasePropsToWatch: (keyof QueueBase)[] = ['status', 'name'];
const meetingPropsToWatch: (keyof Meeting)[] = ['backend_type', 'assignee'];

interface HumanReadableMap {
[key: string]: string;
}

const propertyMap: HumanReadableMap = {
'backend_type': 'meeting type',
'assignee': 'host'
}

function detectChanges<T extends ComparableEntity>(versOne: T, versTwo: T, propsToWatch: (keyof T)[]): string | undefined {
for (const property of propsToWatch) {
let valueOne = versOne[property] as T[keyof T] | string;
let valueTwo = versTwo[property] as T[keyof T] | string;
// Check for nested user objects and falsy values
if (isUser(valueOne)) valueOne = valueOne.username;
if (!valueOne) valueOne = 'None';
if (isUser(valueTwo)) valueTwo = valueTwo.username;
if (!valueTwo) valueTwo = 'None';
if (valueOne !== valueTwo) {
// Make some property strings more human readable
const propName = (property in propertyMap) ? propertyMap[property as string] : property;
return `The ${propName} changed from "${valueOne}" to "${valueTwo}".`;
}
}
return;
}


// https://lodash.com/docs/4.17.15#xorWith

export function compareEntities<T extends ComparableEntity> (oldOnes: T[], newOnes: T[]): string | undefined
{
const symDiff = xorWith(oldOnes, newOnes, isEqual);
if (symDiff.length === 0) return;
const firstEntity = symDiff[0];
const secondEntity = symDiff.length > 1 ? symDiff[1] : undefined;

let entityType;
let permIdentifier;
if (isMeeting(firstEntity)) {
entityType = 'meeting';
// meeting.attendees may change in the future?
permIdentifier = `attendee ${firstEntity.attendees[0].username}`;
} else if (isQueueBase(firstEntity)) {
entityType = 'queue';
permIdentifier = `ID number ${firstEntity.id}`;
} else {
console.error(`compareEntities was used with an unsupported type: ${firstEntity}`)
return;
}

let message;
if (oldOnes.length < newOnes.length) {
return `A new ${entityType} with ${permIdentifier} was added.`;
} else if (oldOnes.length > newOnes.length) {
return `The ${entityType} with ${permIdentifier} was deleted.`;
} else {
let changeDetected;
if (secondEntity) {
if (isMeeting(firstEntity) && isMeeting(secondEntity)) {
changeDetected = detectChanges<Meeting>(firstEntity, secondEntity, meetingPropsToWatch);
if (!changeDetected && firstEntity.status !== secondEntity.status && secondEntity.status === MeetingStatus.STARTED) {
changeDetected = 'The status indicates the meeting is now in progress.';
}
} else if (isQueueBase(firstEntity) && isQueueBase(secondEntity)) {
changeDetected = detectChanges<QueueBase>(firstEntity, secondEntity, queueBasePropsToWatch);
}
}
message = `The ${entityType} with ${permIdentifier} was changed.`;
if (changeDetected) {
message = message + ' ' + changeDetected;
return message;
}
}
return;
}
40 changes: 40 additions & 0 deletions src/assets/src/components/changeLog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as React from "react";
import { useEffect } from "react";
import { Alert } from "react-bootstrap";

import { ChangeEvent } from "../changes";


// https://stackoverflow.com/a/56777520

interface TimedChangeAlertProps {
changeEvent: ChangeEvent,
deleteChangeEvent: (id: number) => void;
}

function TimedChangeAlert (props: TimedChangeAlertProps) {
const deleteEvent = () => props.deleteChangeEvent(props.changeEvent.eventID);

useEffect(() => {
const timeoutID = setTimeout(deleteEvent, 7000);
return () => clearTimeout(timeoutID);
}, []);

return (
<Alert variant='info' aria-live='polite' dismissible={true} onClose={deleteEvent}>
{props.changeEvent.text}
</Alert>
);
}

interface ChangeLogProps {
changeEvents: ChangeEvent[];
deleteChangeEvent: (id: number) => void;
}

export function ChangeLog (props: ChangeLogProps) {
const changeAlerts = props.changeEvents.map(
(e) => <TimedChangeAlert key={e.eventID} changeEvent={e} deleteChangeEvent={props.deleteChangeEvent}/>
);
return <div id='change-log'>{changeAlerts}</div>;
}
19 changes: 15 additions & 4 deletions src/assets/src/components/manage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import * as React from "react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Button } from "react-bootstrap";

import { ChangeLog } from "./changeLog";
import { Breadcrumbs, checkForbiddenError, ErrorDisplay, FormError, LoginDialog, QueueTable } from "./common";
import { PageProps } from "./page";
import { useEntityChanges } from "../hooks/useEntityChanges";
import { usePreviousState } from "../hooks/usePreviousState";
import { QueueBase } from "../models";
import { useUserWebSocket } from "../services/sockets";
import { redirectToLogin } from "../utils";
Expand Down Expand Up @@ -35,23 +38,31 @@ export function ManagePage(props: PageProps) {
if (!props.user) {
redirectToLogin(props.loginUrl);
}

const [queues, setQueues] = useState(undefined as ReadonlyArray<QueueBase> | undefined);
const oldQueues = usePreviousState(queues);
const userWebSocketError = useUserWebSocket(props.user!.id, (u) => setQueues(u.hosted_queues));


const [queueChangeEvents, compareAndSetChangeEvents, deleteQueueChangeEvent] = useEntityChanges<QueueBase>();
useEffect(() => {
if (queues && oldQueues) compareAndSetChangeEvents(oldQueues, queues);
}, [queues]);

const errorSources = [
{source: 'User Connection', error: userWebSocketError}
].filter(e => e.error) as FormError[];
const loginDialogVisible = errorSources.some(checkForbiddenError);
const errorDisplay = <ErrorDisplay formErrors={errorSources}/>
const errorDisplay = <ErrorDisplay formErrors={errorSources} />;
const queueTable = queues !== undefined
&& <ManageQueueTable queues={queues} disabled={false}/>
&& <ManageQueueTable queues={queues} disabled={false} />;
return (
<div>
<LoginDialog visible={loginDialogVisible} loginUrl={props.loginUrl} />
<Breadcrumbs currentPageTitle="Manage"/>
{errorDisplay}
<h1>My Meeting Queues</h1>
<p>These are all the queues you are a host of. Select a queue to manage it or add a queue below.</p>
<ChangeLog changeEvents={queueChangeEvents} deleteChangeEvent={deleteQueueChangeEvent} />
{queueTable}
<hr/>
<a target="_blank" href="https://documentation.its.umich.edu/node/1830">
Expand Down
29 changes: 25 additions & 4 deletions src/assets/src/components/queueManager.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import * as React from "react";
import { useState, createRef, ChangeEvent } from "react";
import { useEffect, useState, createRef, ChangeEvent as ReactChangeEvent } from "react";
import { Link } from "react-router-dom";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCog } from "@fortawesome/free-solid-svg-icons";
import { Alert, Button, Col, Form, InputGroup, Modal, Row } from "react-bootstrap";
import Dialog from "react-bootstrap-dialog";

import {
import { ChangeLog } from "./changeLog";
import {
UserDisplay, ErrorDisplay, FormError, checkForbiddenError, LoadingDisplay, DateDisplay,
CopyField, showConfirmation, LoginDialog, Breadcrumbs, DateTimeDisplay, userLoggedOnWarning
} from "./common";
import { DialInContent } from './dialIn';
import { MeetingsInProgressTable, MeetingsInQueueTable } from "./meetingTables";
import { BackendSelector as MeetingBackendSelector, getBackendByName } from "./meetingType";
import { PageProps } from "./page";
import { ChangeEvent as EntityChangeEvent } from "../changes";
import { useEntityChanges } from "../hooks/useEntityChanges";
import { usePreviousState } from "../hooks/usePreviousState";
import { usePromise } from "../hooks/usePromise";
import { useStringValidation } from "../hooks/useValidation";
import {
Expand Down Expand Up @@ -109,9 +113,11 @@ interface QueueManagerProps {
onShowMeetingInfo: (m: Meeting) => void;
onChangeAssignee: (a: User | undefined, m: Meeting) => void;
onStartMeeting: (m: Meeting) => void;
meetingChangeEvents: EntityChangeEvent[];
deleteMeetingChangeEvent: (key: number) => void;
}

function QueueManager(props: QueueManagerProps) {
function QueueManager (props: QueueManagerProps) {
const spacingClass = 'mt-4';

let startedMeetings = [];
Expand Down Expand Up @@ -159,14 +165,19 @@ function QueueManager(props: QueueManagerProps) {
type='switch'
label={currentStatus ? 'Open' : 'Closed'}
checked={props.queue.status === 'open'}
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onSetStatus(!currentStatus)}
onChange={(e: ReactChangeEvent<HTMLInputElement>) => props.onSetStatus(!currentStatus)}
/>
</Col>
</Row>
<Row noGutters className={spacingClass}>
<Col md={2}><div id='created'>Created</div></Col>
<Col md={6}><div aria-labelledby='created'><DateDisplay date={props.queue.created_at} /></div></Col>
</Row>
<Row noGutters className={spacingClass}>
<Col md={12}>
<ChangeLog changeEvents={props.meetingChangeEvents} deleteChangeEvent={props.deleteMeetingChangeEvent} />
</Col>
</Row>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really a technical comment but a stray observation - since these are positioned toward the top, the rest of the page shifts downward a bit when they appear, which might cause misclicks. Not sure what the best way to handle that would be.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, yeah, tricky, it seemed like a simpler implementation to just always report them at the top, but they could be hidden from view or result in some page shifting if there are a lot of meetings in play.

<h2 className={spacingClass}>Meetings in Progress</h2>
<Row noGutters className={spacingClass}><Col md={8}>{cannotReassignHostWarning}</Col></Row>
<Row noGutters className={spacingClass}>
Expand Down Expand Up @@ -260,6 +271,8 @@ export function QueueManagerPage(props: PageProps<QueueManagerPageParams>) {

// Set up basic state
const [queue, setQueue] = useState(undefined as QueueHost | undefined);
const oldQueue = usePreviousState(queue);

const [authError, setAuthError] = useState(undefined as Error | undefined);
const setQueueChecked = (q: QueueAttendee | QueueHost | undefined) => {
if (!q) {
Expand All @@ -273,6 +286,12 @@ export function QueueManagerPage(props: PageProps<QueueManagerPageParams>) {
}
}
const queueWebSocketError = useQueueWebSocket(queueIdParsed, setQueueChecked);

const [meetingChangeEvents, compareAndSetMeetingChangeEvents, deleteMeetingChangeEvent] = useEntityChanges<Meeting>();
useEffect(() => {
if (queue && oldQueue) compareAndSetMeetingChangeEvents(oldQueue.meeting_set, queue.meeting_set);
}, [queue]);

const [visibleMeetingDialog, setVisibleMeetingDialog] = useState(undefined as Meeting | undefined);

const [myUser, setMyUser] = useState(undefined as MyUser | undefined);
Expand Down Expand Up @@ -350,6 +369,8 @@ export function QueueManagerPage(props: PageProps<QueueManagerPageParams>) {
onShowMeetingInfo={setVisibleMeetingDialog}
onChangeAssignee={doChangeAssignee}
onStartMeeting={doStartMeeting}
meetingChangeEvents={meetingChangeEvents}
deleteMeetingChangeEvent={deleteMeetingChangeEvent}
/>
);
return (
Expand Down
27 changes: 27 additions & 0 deletions src/assets/src/hooks/useEntityChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useState } from "react";

import { ChangeEvent, compareEntities, ComparableEntity } from "../changes";


export function useEntityChanges<T extends ComparableEntity>():
[ChangeEvent[], (oldEntities: readonly T[], newEntities: readonly T[]) => void, (key: number) => void]
{
const [changeEvents, setChangeEvents] = useState([] as ChangeEvent[]);
const [nextID, setNextID] = useState(0);

const compareAndSetEventMap = (oldEntities: readonly T[], newEntities: readonly T[]): void => {
ssciolla marked this conversation as resolved.
Show resolved Hide resolved
const changeText = compareEntities<T>(oldEntities.slice(), newEntities.slice());
if (changeText !== undefined) {
const newChangeEvent = { eventID: nextID, text: changeText } as ChangeEvent;
setChangeEvents([...changeEvents, newChangeEvent]);
setNextID(nextID + 1);
}
};

// https://reactjs.org/docs/hooks-reference.html#functional-updates
const deleteChangeEvent = (id: number) => {
setChangeEvents((prevChangeEvents) => prevChangeEvents.filter((e) => id !== e.eventID));
};
ssciolla marked this conversation as resolved.
Show resolved Hide resolved

return [changeEvents, compareAndSetEventMap, deleteChangeEvent];
}
12 changes: 12 additions & 0 deletions src/assets/src/hooks/usePreviousState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useEffect, useRef } from "react";


// https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state

export function usePreviousState(value: any): any {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
24 changes: 19 additions & 5 deletions src/assets/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,23 @@ export interface MeetingBackend {
intl_telephone_url: string | null;
}

export interface User {
interface Base {
id: number;
}

export interface User extends Base {
username: string;
first_name: string;
last_name: string;
attendee_set?: User[];
hosted_queues?: ReadonlyArray<QueueBase>;
}

export const isUser = (value: any): value is User => {
if (!value || typeof value !== 'object') return false;
return 'username' in value && 'first_name' in value && 'last_name' in value;
}

export interface MyUser extends User {
my_queue: QueueAttendee | null;
phone_number: string;
Expand All @@ -44,8 +52,7 @@ export enum MeetingStatus {
STARTED = 2
}

export interface Meeting {
id: number;
export interface Meeting extends Base {
line_place: number | null;
attendees: User[];
agenda: string;
Expand All @@ -56,12 +63,19 @@ export interface Meeting {
status: MeetingStatus;
}

export interface QueueBase {
id: number;
export const isMeeting = (entity: object): entity is Meeting => {
return 'attendees' in entity;
}

export interface QueueBase extends Base {
name: string;
status: "open" | "closed";
}

export const isQueueBase = (entity: object): entity is QueueBase => {
return 'name' in entity && 'status' in entity;
}

export interface QueueFull extends QueueBase {
created_at: string;
description: string;
Expand Down
Loading