import * as React from "react"; import { createRef, useState } from "react"; import { Link } from "react-router-dom"; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { Button, Col, Nav, Row, Tab } from "react-bootstrap"; import Dialog from "react-bootstrap-dialog"; import { Breadcrumbs, checkForbiddenError, ErrorDisplay, FormError, LoadingDisplay, LoginDialog, showConfirmation } from "./common"; import { PageProps } from "./page"; import { GeneralEditor, ManageHostsEditor, MultiTabEditorProps } from "./queueEditors"; import { usePromise } from "../hooks/usePromise"; import { useMeetingTypesValidation, useStringValidation} from "../hooks/useValidation"; import { QueueAttendee, QueueHost, User, isQueueHost } from "../models"; import * as api from "../services/api"; import { useQueueWebSocket } from "../services/sockets"; import { checkIfSetsAreDifferent, recordQueueManagementEvent, redirectToLogin } from "../utils"; import { confirmUserExists, queueDescriptSchema, queueNameSchema, queueLocationSchema } from "../validation"; const buttonSpacing = 'mr-3 mb-3'; interface QueueSettingsProps extends MultiTabEditorProps { // Shared queue: QueueHost; // General Tab onSaveGeneralClick: () => void; onDiscardGeneralClick: () => void; // Delete Queue Tab onDeleteClick: () => void; } // The 'tab-custom' role is used to override a default 'tab' role that resulted in tab links not being keyboard accessible. function QueueSettingsEditor(props: QueueSettingsProps) { return ( <Tab.Container id='add-queue-editor' defaultActiveKey='general'> <Row> <Col md={3} sm={3}> <div className='mt-4'> <Link to={`/manage/${props.queue.id}/`}> <FontAwesomeIcon icon={faChevronLeft} /> Back to Queue </Link> </div> <Nav variant='pills' className='flex-column mt-4'> <Nav.Item> <Nav.Link eventKey='general' role='tab-custom' tabIndex={0} aria-label='General Tab'> General </Nav.Link> </Nav.Item> <Nav.Item> <Nav.Link eventKey='hosts' role='tab-custom' tabIndex={0} aria-label='Manage Hosts Tab'> Manage Hosts </Nav.Link> </Nav.Item> <Nav.Item> <Nav.Link eventKey='delete' role='tab-custom' tabIndex={0} aria-label='Delete Queue Tab'> Delete Queue </Nav.Link> </Nav.Item> </Nav> </Col> <Col md={6} sm={9}> <h1>Settings</h1> <Tab.Content aria-live='polite'> <Tab.Pane eventKey='general'> <GeneralEditor {...props} /> <div className='mt-4'> <Button variant='primary' className={buttonSpacing} disabled={props.disabled} aria-label='Save Changes' onClick={props.onSaveGeneralClick} > Save Changes </Button> <Button variant='link' className={'text-danger ' + buttonSpacing} disabled={props.disabled} aria-label='Discard Changes' onClick={props.onDiscardGeneralClick} > Discard Changes </Button> </div> </Tab.Pane> <Tab.Pane eventKey='hosts'> <ManageHostsEditor {...props} /> </Tab.Pane> <Tab.Pane eventKey='delete'> <h2>Delete Queue</h2> <p>Delete the entire queue, including all hosts and current meetings in queue. <strong>This cannot be undone.</strong></p> <div className='mt-4'> <Button variant='danger' disabled={props.disabled} aria-label='Delete Queue' onClick={props.onDeleteClick}> Delete Queue </Button> </div> </Tab.Pane> </Tab.Content> </Col> </Row> </Tab.Container> ); } interface SettingsPageParams { queue_id: string; } export function ManageQueueSettingsPage(props: PageProps<SettingsPageParams>) { if (!props.user) { redirectToLogin(props.loginUrl); } // Set up page state const dialogRef = createRef<Dialog>(); const [queue, setQueue] = useState(undefined as QueueHost | undefined); const [authError, setAuthError] = useState(undefined as Error | undefined); // Set up WebSocket const queueID = props.match.params.queue_id; if (queueID === undefined) throw new Error("queueID is undefined!"); if (!props.user) throw new Error("user is undefined!"); const queueIDInt = Number(queueID); const [showCorrectGeneralMessage, setShowCorrectGeneralMessage] = useState(false); const [showSuccessMessage, setShowSuccessMessage] = useState(false); const [name, setName] = useState(''); const [nameValidationResult, validateAndSetNameResult, clearNameResult] = useStringValidation(queueNameSchema, true); const [description, setDescription] = useState(''); const [descriptValidationResult, validateAndSetDescriptResult, clearDescriptResult] = useStringValidation(queueDescriptSchema, true); const [allowedMeetingTypes, setAllowedMeetingTypes] = useState(new Set() as Set<string>); const [allowedValidationResult, validateAndSetAllowedResult, clearAllowedResult] = useMeetingTypesValidation(props.backends, queue); const [inpersonLocation, setInpersonLocation] = useState(''); const [locationValidationResult, validateAndSetLocationResult, clearLocationResult] = useStringValidation(queueLocationSchema, true); const setQueueChecked = (q: QueueAttendee | QueueHost | undefined) => { if (!q) { setQueue(q); } else if (isQueueHost(q)) { if (!queue) { setName(q.name); setDescription(q.description); setAllowedMeetingTypes(new Set(q.allowed_backends)); setInpersonLocation(q.inperson_location); } setQueue(q); setAuthError(undefined); } else { setQueue(undefined); setAuthError(new Error("You are not a host of this queue. If you believe you are seeing this message in error, contact the queue host(s).")); } } const userWebSocketError = useQueueWebSocket(queueIDInt, setQueueChecked); const resetValidationResults = () => { setShowCorrectGeneralMessage(false); clearNameResult() clearDescriptResult(); clearAllowedResult(); clearLocationResult(); } // Set up API interactions const updateQueue = async (name?: string, description?: string, inpersonLocation?: string, allowed_backends?: Set<string>) => { recordQueueManagementEvent("Updated Queue Details"); return await api.updateQueue(queue!.id, name, description, inpersonLocation, allowed_backends); } const [doUpdateQueue, updateQueueLoading, updateQueueError] = usePromise(updateQueue, setQueueChecked); const removeHost = async (h: User) => { recordQueueManagementEvent("Removed Host"); await api.removeHost(queue!.id, h.id); } const [doRemoveHost, removeHostLoading, removeHostError] = usePromise(removeHost); const confirmRemoveHost = (h: User) => { showConfirmation(dialogRef, () => doRemoveHost(h), "Remove Host?", `Are you sure you want to remove host ${h.username}?`); } const addHost = async (uniqname: string) => { const user = await confirmUserExists(uniqname); recordQueueManagementEvent("Added Host"); await api.addHost(queue!.id, user.id); } const [doAddHost, addHostLoading, addHostError] = usePromise(addHost); const removeQueue = async () => { recordQueueManagementEvent("Removed Queue"); await api.deleteQueue(queue!.id); location.href = '/manage'; } const [doRemoveQueue, removeQueueLoading, removeQueueError] = usePromise(removeQueue); const confirmRemoveQueue = () => { showConfirmation(dialogRef, () => doRemoveQueue(), "Delete Queue?", "Are you sure you want to permanently delete this queue?"); } // On change handlers const handleNameChange = (newName: string) => { setName(newName); validateAndSetNameResult(newName); setShowSuccessMessage(false); }; const handleDescriptionChange = (newDescription: string) => { setDescription(newDescription); validateAndSetDescriptResult(newDescription); setShowSuccessMessage(false); }; const handleAllowedChange = (newAllowedBackends: Set<string>) => { setAllowedMeetingTypes(newAllowedBackends); validateAndSetAllowedResult(newAllowedBackends); setShowSuccessMessage(false); }; const handleLocationChange = (newInpersonLocation: string) => { setInpersonLocation(newInpersonLocation); validateAndSetLocationResult(newInpersonLocation); setShowSuccessMessage(false); }; // On click handlers const handleSaveGeneralClick = () => { const curNameValidationResult = !nameValidationResult ? validateAndSetNameResult(name) : nameValidationResult; const curDescriptValidationResult = !descriptValidationResult ? validateAndSetDescriptResult(description) : descriptValidationResult; const curAllowedValidationResult = !allowedValidationResult ? validateAndSetAllowedResult(allowedMeetingTypes) : allowedValidationResult; const curLocationValidationResult = !locationValidationResult ? validateAndSetLocationResult(inpersonLocation) : locationValidationResult; if ( !curNameValidationResult.isInvalid && !curDescriptValidationResult.isInvalid && !curAllowedValidationResult.isInvalid && !curLocationValidationResult.isInvalid ) { const nameForUpdate = name.trim() !== queue?.name ? name : undefined; const descriptForUpdate = description.trim() !== queue?.description ? description : undefined; const allowedForUpdate = checkIfSetsAreDifferent(new Set(queue!.allowed_backends), allowedMeetingTypes) ? allowedMeetingTypes : undefined; const locationForUpdate = inpersonLocation.trim() !== queue?.inperson_location ? inpersonLocation : undefined; if (nameForUpdate !== undefined || descriptForUpdate !== undefined || allowedForUpdate || locationForUpdate !== undefined) { doUpdateQueue(nameForUpdate, descriptForUpdate, locationForUpdate, allowedForUpdate); setShowSuccessMessage(true); } resetValidationResults(); } else { setShowCorrectGeneralMessage(true); } }; const handleDiscardGeneralClick = () => { setName(queue!.name); setDescription(queue!.description); setAllowedMeetingTypes(new Set(queue!.allowed_backends)); setInpersonLocation(queue!.inperson_location); resetValidationResults(); } const isChanging = updateQueueLoading || addHostLoading || removeHostLoading || removeQueueLoading; const globalErrors = [ {source: 'Access Denied', error: authError}, {source: 'Queue Connection', error: userWebSocketError}, {source: 'Update Queue', error: updateQueueError}, {source: 'Remove Host', error: removeHostError}, {source: 'Remove Queue', error: removeQueueError} ].filter(e => e.error) as FormError[]; const loginDialogVisible = globalErrors.some(checkForbiddenError); const queueSettingsEditor = queue && ( <QueueSettingsEditor queue={queue} disabled={isChanging} name={name} nameValidationResult={nameValidationResult} onChangeName={handleNameChange} description={description} descriptValidationResult={descriptValidationResult} onChangeDescription={handleDescriptionChange} backends={props.backends} allowedMeetingTypes={allowedMeetingTypes} allowedValidationResult={allowedValidationResult} onChangeAllowed={handleAllowedChange} showCorrectGeneralMessage={showCorrectGeneralMessage} showSuccessMessage={showSuccessMessage} onSaveGeneralClick={handleSaveGeneralClick} onDiscardGeneralClick={handleDiscardGeneralClick} currentUser={props.user} hosts={queue.hosts} onAddHost={doAddHost} onRemoveHost={confirmRemoveHost} checkHostError={addHostError ? { source: 'Add Host', error: addHostError } : undefined} onDeleteClick={confirmRemoveQueue} inpersonLocation={inpersonLocation} locationValidationResult={locationValidationResult} onChangeLocation={handleLocationChange} /> ); return ( <div> <Dialog ref={dialogRef} /> <LoginDialog visible={loginDialogVisible} loginUrl={props.loginUrl} /> <Breadcrumbs currentPageTitle='Settings' intermediatePages={[ {title: 'Manage', href: '/manage'}, {title: queue?.name ?? queueIDInt.toString(), href: `/manage/${queueIDInt}`} ]} /> <LoadingDisplay loading={isChanging} /> <ErrorDisplay formErrors={globalErrors} /> {queueSettingsEditor} </div> ); }