From 28a2e4595754f11ef8155d7de003984c76974e60 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Mon, 17 Jul 2023 22:12:35 -0700 Subject: [PATCH] feat(Messaging): default thread notif audience settings --- client/src/components/Chat/index.jsx | 101 +++++++---------- .../components/Messaging/EditThreadModal.tsx | 97 +++++++++++++++++ client/src/components/Messaging/index.jsx | 103 ++++++++++++++---- .../projects/TaskComponents/ViewTaskModal.tsx | 1 + client/src/types/Thread.ts | 9 ++ client/src/types/index.ts | 2 + client/src/utils/threadsHelpers.ts | 26 +++++ server/api.js | 9 +- server/api/messaging.js | 78 ++++++++++++- server/api/projects.js | 2 + server/models/thread.ts | 7 ++ 11 files changed, 350 insertions(+), 85 deletions(-) create mode 100644 client/src/components/Messaging/EditThreadModal.tsx create mode 100644 client/src/types/Thread.ts create mode 100644 client/src/utils/threadsHelpers.ts diff --git a/client/src/components/Chat/index.jsx b/client/src/components/Chat/index.jsx index b2818829..859f8203 100644 --- a/client/src/components/Chat/index.jsx +++ b/client/src/components/Chat/index.jsx @@ -19,6 +19,7 @@ import TextArea from '../TextArea'; import { isEmptyString } from '../util/HelperFunctions.js'; import useGlobalError from '../error/ErrorHooks'; import './Chat.css'; +import { THREADS_NOTIFY_OPTIONS, getThreadNotifyOption } from '../../utils/threadsHelpers'; /** * A reusable chat/message thread interface. @@ -30,6 +31,7 @@ const Chat = ({ kind, activeThread, activeThreadTitle, + activeThreadDefaultNotifSubject, activeThreadMsgs, loadedThreadMsgs, getThreads, @@ -53,10 +55,36 @@ const Chat = ({ const [messageSending, setMessageSending] = useState(false); // Notify Settings + const [notifySetting, setNotifySetting] = useState('all'); const [showNotifyPicker, setShowNotifyPicker] = useState(false); const [projectTeam, setProjectTeam] = useState([]); const [teamToNotify, setTeamToNotify] = useState([]); const [loadingTeam, setLoadingTeam] = useState(false); + const [notificationOptions, setNotificationOptions] = useState([...THREADS_NOTIFY_OPTIONS]); + + // Set the available notification options based on the thread type + useEffect(() => { + if (kind === "task") { + setNotificationOptions([ + { key: "assigned", text: "Notify assignees", value: "assigned" }, + ...THREADS_NOTIFY_OPTIONS, + ]); + } + }, [kind]); + + // Set the default notification setting based on the thread type & settings + useEffect(() => { + if (kind === "task") { + handleNotifySettingChange('assigned') + return; + } + if (activeThreadDefaultNotifSubject) { + handleNotifySettingChange(activeThreadDefaultNotifSubject) + return; + } + + handleNotifySettingChange('all') + }, [activeThread, activeThreadDefaultNotifSubject]); /** * Retrieves a list of team members in the current Project from the server and saves it to state. @@ -93,57 +121,6 @@ const Chat = ({ setShowNotifyPicker(true); }, [getProjectTeam]); - const notificationOptions = useMemo(() => { - const NOTIFY_OPTIONS = [ - { - key: 'all', - text: 'Notify entire team', - value: 'all', - }, - { - key: 'specific', - text: 'Notify specific people...', - value: 'specific', - onClick: handleOpenNotifyPicker, - }, - { - key: 'support', - text: 'Notify LibreTexts Support', - value: 'support', - }, - { - key: 'none', - text: `Don't notify anyone`, - value: 'none', - }, - ]; - if (kind === 'task') { - return [ - { key: 'assigned', text: 'Notify assignees', value: 'assigned' }, - ...NOTIFY_OPTIONS, - ] - } - return NOTIFY_OPTIONS; - }, [kind, handleOpenNotifyPicker]); - - const defaultNotificationSetting = useMemo(() => { - if (kind === 'task') { - return 'assigned'; - } - return 'all'; - }, [kind]); - - const [notifySetting, setNotifySetting] = useState(defaultNotificationSetting); - - const notifySettingDropdownText = useMemo(() => { - if (notifySetting === 'specific') { - const modifier = teamToNotify.length > 1 ? 'people' : 'person'; - return `Notify ${teamToNotify.length} ${modifier}`; - } - const foundOption = notificationOptions.find((item) => item.value === notifySetting); - return foundOption.text; - }, [notifySetting, notificationOptions, teamToNotify]); - /** * Register plugins on load. */ @@ -281,13 +258,12 @@ const Chat = ({ * Saves changes in the selected notification setting to state. If the new setting * is `specific`, the Notify People Picker is opened. * - * @param {object} e - Event that activated the handler. - * @param {object} data - Data passed from the UI component. * @param {string} data.value - The new notification setting. */ - function handleNotifySettingChange(_e, { value }) { - if (value !== 'specific') { - setNotifySetting(value); + function handleNotifySettingChange(value) { + setNotifySetting(value); + if (value === "specific") { + handleOpenNotifyPicker(); } }; @@ -384,15 +360,20 @@ const Chat = ({ fluid > - Send + { + (notifySetting === 'specific') ? ( + Send to {teamToNotify.length} {teamToNotify.length === 1 ? 'person' : 'people'} + ) : ( + Send + ) + } handleNotifySettingChange(value ?? 'all')} /> @@ -400,7 +381,7 @@ const Chat = ({ Choose People to Notify -

Choose which team members to notify

+

Choose which team members to notify about this message

void; + onSave: () => void; + thread: Partial; +}; +const EditThreadModal: React.FC = ({ + show, + onClose, + onSave, + thread, +}) => { + const { handleGlobalError } = useGlobalError(); + const [threadTitle, setThreadTitle] = useState(""); + const [threadDefaultNotifSubject, setThreadDefaultNotifSubject] = + useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (show && thread.threadID) { + setThreadTitle(thread.title ?? ""); + setThreadDefaultNotifSubject(thread.defaultNotifSubject ?? ""); + } + }, [show, thread]); + + async function save() { + try { + if (!thread.threadID || !threadTitle || !threadDefaultNotifSubject) { + return; + } + setLoading(true); + + const updateRes = await axios.patch("/project/thread", { + threadID: thread.threadID, + title: threadTitle, + defaultNotifSubject: threadDefaultNotifSubject, + }); + + if (updateRes.data.err) { + throw new Error(updateRes.data.errMsg); + } + onSave(); + } catch (err) { + handleGlobalError(err); + } finally { + setLoading(false); + } + } + return ( + + Create a Thread + +
+ + + setThreadTitle(e.target.value)} + value={threadTitle} + /> + + + + { + setThreadDefaultNotifSubject(value?.toString() ?? ""); + }} + value={threadDefaultNotifSubject} + /> + +
+
+ + + + +
+ ); +}; + +export default EditThreadModal; diff --git a/client/src/components/Messaging/index.jsx b/client/src/components/Messaging/index.jsx index 1d9620f1..f66e198e 100644 --- a/client/src/components/Messaging/index.jsx +++ b/client/src/components/Messaging/index.jsx @@ -13,7 +13,8 @@ import { Icon, Loader, Form, - Input + Input, + Dropdown } from 'semantic-ui-react'; import Chat from '../Chat'; import { @@ -22,6 +23,8 @@ import { } from '../util/HelperFunctions.js'; import useGlobalError from '../error/ErrorHooks'; import './Messaging.css'; +import { THREADS_NOTIFY_OPTIONS } from '../../utils/threadsHelpers'; +import EditThreadModal from './EditThreadModal'; /** * A reusable messaging (threads and chat window) interface. @@ -34,17 +37,22 @@ const Messaging = ({ projectID, user, kind, isProjectAdmin }) => { // New Thread Modal const [showNewThreadModal, setShowNewThreadModal] = useState(false); const [newThreadTitle, setNewThreadTitle] = useState(''); + const [newThreadDefaultNotifSubject, setNewThreadDefaultNotifSubject] = useState(''); const [newThreadLoading, setNewThreadLoading] = useState(false); // Delete Thread Modal const [showDelThreadModal, setShowDelThreadModal] = useState(false); const [delThreadLoading, setDelThreadLoading] = useState(false); + // Edit Thread Modal + const [showEditThreadModal, setShowEditThreadModal] = useState(false); + // Discussion const [threads, setThreads] = useState([]); const [loadedThreads, setLoadedThreads] = useState(false); const [activeThread, setActiveThread] = useState(''); const [activeThreadTitle, setActiveThreadTitle] = useState(''); + const [activeThreadDefaultNotifSubject, setActiveThreadDefaultNotifSubject] = useState(''); const [activeThreadMsgs, setActiveThreadMsgs] = useState([]); const [loadedThreadMsgs, setLoadedThreadMsgs] = useState(false); @@ -119,35 +127,44 @@ const Messaging = ({ projectID, user, kind, isProjectAdmin }) => { const activateThread = (thread) => { setActiveThread(thread.threadID); setActiveThreadTitle(thread.title); + setActiveThreadDefaultNotifSubject(thread.defaultNotifSubject ?? 'all'); }; - const submitNewThread = () => { - if (!isEmptyString(newThreadTitle)) { - setNewThreadLoading(true); - axios.post('/project/thread', { - projectID: projectID, - title: newThreadTitle, - kind: kind - }).then((res) => { - if (!res.data.err) { - getDiscussionThreads(); - closeNewThreadModal(); - } else { - handleGlobalError(res.data.errMsg); - setNewThreadLoading(false); - } - }).catch((err) => { - handleGlobalError(err); - setNewThreadLoading(false); - }); + const submitNewThread = async () => { + try { + if ( + isEmptyString(newThreadTitle) || + isEmptyString(newThreadDefaultNotifSubject) + ) { + return; } + setNewThreadLoading(true); + const createRes = await axios.post("/project/thread", { + projectID: projectID, + title: newThreadTitle, + kind: kind, + defaultNotifSubject: newThreadDefaultNotifSubject, + }); + if (!createRes.data.err) { + getDiscussionThreads(); + closeNewThreadModal(); + } else { + handleGlobalError(createRes.data.errMsg); + setNewThreadLoading(false); + } + } catch (err) { + handleGlobalError(err); + } finally { + setNewThreadLoading(false); + } }; const openNewThreadModal = () => { setNewThreadLoading(false); setNewThreadTitle(''); + setNewThreadDefaultNotifSubject(''); setShowNewThreadModal(true); }; @@ -155,9 +172,17 @@ const Messaging = ({ projectID, user, kind, isProjectAdmin }) => { const closeNewThreadModal = () => { setShowNewThreadModal(false); setNewThreadLoading(false); + setNewThreadDefaultNotifSubject(''); setNewThreadTitle(''); }; + const handleEditThreadModalSave = () => { + getDiscussionThreads(); + setShowEditThreadModal(false); + setActiveThread(''); + setActiveThreadTitle(''); + setActiveThreadDefaultNotifSubject(''); + } const submitDeleteThread = () => { if (!isEmptyString(activeThread)) { @@ -215,6 +240,21 @@ const Messaging = ({ projectID, user, kind, isProjectAdmin }) => { > + { + activeThread && isProjectAdmin && ( + + ) + }