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

Draft
wants to merge 22 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
132 changes: 132 additions & 0 deletions src/assets/src/changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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'];


// Value Transformations

type ValueTransform = (value: any) => any;

const transformUserToUsername: ValueTransform = (value) => {
return isUser(value) ? value.username : value;
};

const transformFalsyToNone: ValueTransform = (value) => {
return value === null || value === undefined || value === '' ? 'None' : value;
}

function transformValue (value: any, transforms: ValueTransform[]): any {
let newValue = value;
for (const transform of transforms) {
newValue = transform(newValue);
}
return newValue;
}

const standardTransforms = [transformUserToUsername, transformFalsyToNone];


// Property Transformations

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

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

const transformProperty = (value: string, propertyMap: HumanReadableMap) => {
return (value in propertyMap) ? propertyMap[value] : value;
}


// Core functions

function detectChanges<T extends ComparableEntity> (
versOne: T, versTwo: T, propsToWatch: (keyof T)[], transforms: ValueTransform[]): string[]
{
let changedPropMessages = [];
for (const property of propsToWatch) {
let valueOne = versOne[property] as T[keyof T] | string;
let valueTwo = versTwo[property] as T[keyof T] | string;
valueOne = transformValue(valueOne, transforms);
valueTwo = transformValue(valueTwo, transforms);
if (valueOne !== valueTwo) {
const propName = transformProperty(property as string, humanReadablePropertyMap);
changedPropMessages.push(`The ${propName} changed from "${valueOne}" to "${valueTwo}".`);
}
}
return changedPropMessages;
}


// Any new types added to ComparableEntity need to be supported in this function.

function describeEntity (entity: ComparableEntity): string[] {
let entityType;
let permIdent;
if (isMeeting(entity)) {
entityType = 'meeting';
permIdent = `attendee ${entity.attendees[0].username}`;
} else {
// Don't need to check if it's a QueueBase because it's the only other option
entityType = 'queue';
permIdent = `ID number ${entity.id}`;
}
return [entityType, permIdent];
}


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

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

const oldIDs = oldOnes.map((value) => value.id);
const newIDs = newOnes.map((value) => value.id);

let changeMessages: string[] = [];
let processedChangedObjectIDs: number[] = [];
for (const entity of symDiff) {
if (processedChangedObjectIDs.includes(entity.id)) continue;
const [entityType, permIdent] = describeEntity(entity);
if (oldIDs.includes(entity.id) && !newIDs.includes(entity.id)) {
changeMessages.push(`The ${entityType} with ${permIdent} was deleted.`);
} else if (!oldIDs.includes(entity.id) && newIDs.includes(entity.id)) {
changeMessages.push(`A new ${entityType} with ${permIdent} was added.`);
} else {
// Assuming based on context that symDiff.length === 2
const [firstEntity, secondEntity] = symDiff.filter(value => value.id === entity.id);
let changesDetected: string[] = [];
if (isMeeting(firstEntity) && isMeeting(secondEntity)) {
const changes = detectChanges<Meeting>(firstEntity, secondEntity, meetingPropsToWatch, standardTransforms);
if (changes.length > 0) changesDetected.push(...changes);
// Custom check for Meeting.status, since only some status changes are relevant here.
if (firstEntity.status !== secondEntity.status && secondEntity.status === MeetingStatus.STARTED) {
changesDetected.push('The meeting is now in progress.');
}
} else if (isQueueBase(firstEntity) && isQueueBase(secondEntity)) {
const changes = detectChanges<QueueBase>(firstEntity, secondEntity, queueBasePropsToWatch, standardTransforms);
if (changes.length > 0) changesDetected.push(...changes);
}
if (changesDetected.length > 0) {
changeMessages.push(`The ${entityType} with ${permIdent} was changed. ` + changesDetected.join(' '));
}
processedChangedObjectIDs.push(entity.id)
}
}
return changeMessages;
}
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
34 changes: 34 additions & 0 deletions src/assets/src/hooks/useEntityChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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 compareAndSetChangeEvents = (oldEntities: readonly T[], newEntities: readonly T[]): void => {
const changeMessages = compareEntities<T>(oldEntities.slice(), newEntities.slice());
if (changeMessages.length > 0) {
let eventID = nextID;
const newChangeEvents = changeMessages.map(
(m) => {
const newEvent = { eventID: eventID, text: m } as ChangeEvent;
eventID++;
return newEvent;
}
)
setChangeEvents([...changeEvents].concat(newChangeEvents));
setNextID(eventID);
}
};

// 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, compareAndSetChangeEvents, 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;
}
Loading