diff --git a/src/assets/src/changes.ts b/src/assets/src/changes.ts new file mode 100644 index 00000000..bb42b64a --- /dev/null +++ b/src/assets/src/changes.ts @@ -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 ( + 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 (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(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(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; +} diff --git a/src/assets/src/components/changeLog.tsx b/src/assets/src/components/changeLog.tsx new file mode 100644 index 00000000..220b9c78 --- /dev/null +++ b/src/assets/src/components/changeLog.tsx @@ -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 ( + + {props.changeEvent.text} + + ); +} + +interface ChangeLogProps { + changeEvents: ChangeEvent[]; + deleteChangeEvent: (id: number) => void; +} + +export function ChangeLog (props: ChangeLogProps) { + const changeAlerts = props.changeEvents.map( + (e) => + ); + return
{changeAlerts}
; +} diff --git a/src/assets/src/components/manage.tsx b/src/assets/src/components/manage.tsx index cc4cd878..a8c6d794 100644 --- a/src/assets/src/components/manage.tsx +++ b/src/assets/src/components/manage.tsx @@ -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"; @@ -35,16 +38,23 @@ export function ManagePage(props: PageProps) { if (!props.user) { redirectToLogin(props.loginUrl); } + const [queues, setQueues] = useState(undefined as ReadonlyArray | undefined); + const oldQueues = usePreviousState(queues); const userWebSocketError = useUserWebSocket(props.user!.id, (u) => setQueues(u.hosted_queues)); - + + const [queueChangeEvents, compareAndSetChangeEvents, deleteQueueChangeEvent] = useEntityChanges(); + 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 = + const errorDisplay = ; const queueTable = queues !== undefined - && + && ; return (
@@ -52,6 +62,7 @@ export function ManagePage(props: PageProps) { {errorDisplay}

My Meeting Queues

These are all the queues you are a host of. Select a queue to manage it or add a queue below.

+ {queueTable}
diff --git a/src/assets/src/components/queueManager.tsx b/src/assets/src/components/queueManager.tsx index 796fc563..d7cace9c 100644 --- a/src/assets/src/components/queueManager.tsx +++ b/src/assets/src/components/queueManager.tsx @@ -1,12 +1,13 @@ 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"; @@ -14,6 +15,9 @@ 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 { @@ -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 = []; @@ -159,7 +165,7 @@ function QueueManager(props: QueueManagerProps) { type='switch' label={currentStatus ? 'Open' : 'Closed'} checked={props.queue.status === 'open'} - onChange={(e: ChangeEvent) => props.onSetStatus(!currentStatus)} + onChange={(e: ReactChangeEvent) => props.onSetStatus(!currentStatus)} /> @@ -167,6 +173,11 @@ function QueueManager(props: QueueManagerProps) {
Created
+ + + + +

Meetings in Progress

{cannotReassignHostWarning} @@ -260,6 +271,8 @@ export function QueueManagerPage(props: PageProps) { // 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) { @@ -273,6 +286,12 @@ export function QueueManagerPage(props: PageProps) { } } const queueWebSocketError = useQueueWebSocket(queueIdParsed, setQueueChecked); + + const [meetingChangeEvents, compareAndSetMeetingChangeEvents, deleteMeetingChangeEvent] = useEntityChanges(); + 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); @@ -350,6 +369,8 @@ export function QueueManagerPage(props: PageProps) { onShowMeetingInfo={setVisibleMeetingDialog} onChangeAssignee={doChangeAssignee} onStartMeeting={doStartMeeting} + meetingChangeEvents={meetingChangeEvents} + deleteMeetingChangeEvent={deleteMeetingChangeEvent} /> ); return ( diff --git a/src/assets/src/hooks/useEntityChanges.ts b/src/assets/src/hooks/useEntityChanges.ts new file mode 100644 index 00000000..7e6bdf7f --- /dev/null +++ b/src/assets/src/hooks/useEntityChanges.ts @@ -0,0 +1,34 @@ +import { useState } from "react"; + +import { ChangeEvent, compareEntities, ComparableEntity } from "../changes"; + + +export function useEntityChanges(): + [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(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)); + }; + + return [changeEvents, compareAndSetChangeEvents, deleteChangeEvent]; +} diff --git a/src/assets/src/hooks/usePreviousState.ts b/src/assets/src/hooks/usePreviousState.ts new file mode 100644 index 00000000..8a30d2fa --- /dev/null +++ b/src/assets/src/hooks/usePreviousState.ts @@ -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; +} diff --git a/src/assets/src/models.ts b/src/assets/src/models.ts index a45b2222..f7a9b37e 100644 --- a/src/assets/src/models.ts +++ b/src/assets/src/models.ts @@ -10,8 +10,11 @@ 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; @@ -19,6 +22,11 @@ export interface User { hosted_queues?: ReadonlyArray; } +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; @@ -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; @@ -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; diff --git a/src/package-lock.json b/src/package-lock.json index c017a526..01036284 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1556,6 +1556,28 @@ "@types/istanbul-lib-report": "*" } }, + "@types/lodash": { + "version": "4.14.165", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.165.tgz", + "integrity": "sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg==" + }, + "@types/lodash.isequal": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.5.tgz", + "integrity": "sha512-4IKbinG7MGP131wRfceK6W4E/Qt3qssEFLF30LnJbjYiSfHGGRU/Io8YxXrZX109ir+iDETC8hw8QsDijukUVg==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, + "@types/lodash.xorwith": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@types/lodash.xorwith/-/lodash.xorwith-4.5.6.tgz", + "integrity": "sha512-fs13IjIVwzPC8kO+/nzhnyUkuou8gyBY0k1WlZWWGQMc+Un3SJHOixpG9OA+bXLWF6JvEOIBIkuPzQ4AKMHwGQ==", + "requires": { + "@types/lodash": "*" + } + }, "@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -8351,6 +8373,11 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8372,6 +8399,11 @@ "resolved": "https://registry.npmjs.org/lodash.startswith/-/lodash.startswith-4.2.1.tgz", "integrity": "sha1-xZjErc4YiiflMUVzHNxsDnF3YAw=" }, + "lodash.xorwith": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.xorwith/-/lodash.xorwith-4.5.0.tgz", + "integrity": "sha1-jdFQIzXZatyeRtmlbsO+TvJvwqc=" + }, "log-symbols": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", diff --git a/src/package.json b/src/package.json index df45695d..ef2b2d87 100644 --- a/src/package.json +++ b/src/package.json @@ -5,6 +5,8 @@ "@fortawesome/fontawesome-svg-core": "^1.2.28", "@fortawesome/free-solid-svg-icons": "^5.13.0", "@fortawesome/react-fontawesome": "^0.1.9", + "lodash.isequal": "^4.5.0", + "lodash.xorwith": "^4.5.0", "react": "^16.10.2", "react-app-polyfill": "~0.2.2", "react-bootstrap": "^1.4.0", @@ -24,6 +26,8 @@ "check-types": "tsc" }, "devDependencies": { + "@types/lodash.isequal": "^4.5.5", + "@types/lodash.xorwith": "^4.5.6", "@types/react": "^16.9.23", "@types/react-dom": "^16.9.5", "@types/react-router": "^5.1.4", diff --git a/src/tsconfig.json b/src/tsconfig.json index eb5fa728..09f0aa7d 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -7,7 +7,8 @@ "esModuleInterop": true, "jsx": "react", "skipLibCheck": true, - "sourceMap": true + "sourceMap": true, + "allowSyntheticDefaultImports": true }, "include": [ "assets/src"