From aaf0822ebea282306b6452bbc841fe4d95490275 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Thu, 31 Oct 2024 17:55:46 +0000 Subject: [PATCH] adds bulk actions --- .../FrontDoor/Controls/ConversationItem.tsx | 235 +++++++++++------- .../FrontDoor/Controls/ConversationList.tsx | 145 ++++++++++- .../src/libs/useConversationUtility.ts | 53 +++- 3 files changed, 342 insertions(+), 91 deletions(-) diff --git a/workbench-app/src/components/FrontDoor/Controls/ConversationItem.tsx b/workbench-app/src/components/FrontDoor/Controls/ConversationItem.tsx index d9942ac0..a340c53b 100644 --- a/workbench-app/src/components/FrontDoor/Controls/ConversationItem.tsx +++ b/workbench-app/src/components/FrontDoor/Controls/ConversationItem.tsx @@ -3,6 +3,7 @@ import { Caption1, Card, CardHeader, + Checkbox, makeStyles, Menu, MenuItem, @@ -53,6 +54,11 @@ const useClasses = makeStyles({ justifyContent: 'space-between', width: '100%', }, + action: { + flex: '0 0 auto', + display: 'flex', + flexDirection: 'column', + }, pin: { flex: '0 0 auto', }, @@ -67,9 +73,6 @@ const useClasses = makeStyles({ marginLeft: tokens.spacingHorizontalXS, flexShrink: 0, }, - hidden: { - visibility: 'hidden', - }, unread: { color: tokens.colorStrokeFocus2, fontWeight: '600', @@ -80,26 +83,60 @@ const useClasses = makeStyles({ textOverflow: 'ellipsis', width: '100%', }, + showingActions: { + '& .fui-CardHeader__header': { + width: 'calc(100% - 40px)', + }, + + '& .fui-CardHeader__description': { + width: 'calc(100% - 40px)', + }, + }, + selectCheckbox: { + height: '24px', + }, + moreButton: { + paddingTop: 0, + paddingBottom: 0, + }, }); interface ConversationItemProps { conversation: Conversation; owned?: boolean; selected?: boolean; + showSelectForActions?: boolean; + selectedForActions?: boolean; onSelect?: (conversation: Conversation) => void; onExport?: (conversation: Conversation) => void; onRename?: (conversation: Conversation) => void; onDuplicate?: (conversation: Conversation) => void; onShare?: (conversation: Conversation) => void; onRemove?: (conversation: Conversation) => void; + onSelectForActions?: (conversation: Conversation, selected: boolean) => void; } export const ConversationItem: React.FC = (props) => { - const { conversation, owned, selected, onSelect, onExport, onRename, onDuplicate, onShare, onRemove } = props; + const { + conversation, + owned, + selected, + onSelect, + onExport, + onRename, + onDuplicate, + onShare, + onRemove, + showSelectForActions, + selectedForActions, + onSelectForActions, + } = props; const classes = useClasses(); const [isHovered, setIsHovered] = React.useState(false); const { hasUnreadMessages, isPinned, setPinned } = useConversationUtility(); + const showActions = isHovered || showSelectForActions; + const action = React.useMemo(() => { const handleMenuItemClick = ( event: React.MouseEvent, @@ -115,70 +152,101 @@ export const ConversationItem: React.FC = (props) => { }; return ( - - - - + {onRename && ( + } + onClick={(event) => handleMenuItemClick(event, onRename)} + disabled={!owned} + > + Rename + + )} + {onExport && ( + } + onClick={(event) => handleMenuItemClick(event, onExport)} + > + Export + + )} + {onDuplicate && ( + } + onClick={(event) => handleMenuItemClick(event, onDuplicate)} + > + Duplicate + + )} + {onShare && ( + } + onClick={(event) => handleMenuItemClick(event, onShare)} + > + Share + + )} + {onRemove && ( + } + onClick={(event) => handleMenuItemClick(event, onRemove)} + disabled={selected} + > + {selected ? ( + + Remove + + ) : ( + 'Remove' + )} + + )} + + + + ); - }, [conversation, isPinned, onRename, owned, onExport, onDuplicate, onShare, onRemove, selected, setPinned]); + }, [ + classes.action, + classes.selectCheckbox, + classes.moreButton, + selectedForActions, + conversation, + isPinned, + onRename, + owned, + onExport, + onDuplicate, + onShare, + onRemove, + selected, + setPinned, + onSelectForActions, + ]); const unread = hasUnreadMessages(conversation); @@ -197,26 +265,21 @@ export const ConversationItem: React.FC = (props) => { {conversation.title} - - {formattedDate} - + {!showActions && ( + + {formattedDate} + + )} ); }, [ - classes.pin, - classes.date, + conversation, classes.header, - classes.hidden, + classes.pin, classes.title, + classes.date, classes.unread, - conversation, - isHovered, + showActions, isPinned, unread, ]); @@ -230,12 +293,8 @@ export const ConversationItem: React.FC = (props) => { const sender = conversation.participants.find((p) => p.id === participantId); const content = conversation.latest_message.content; - return ( - - {sender ? `${sender.name}: ${content}` : content} - - ); - }, [conversation.latest_message, conversation.participants, classes.description, classes.unread, unread]); + return {sender ? `${sender.name}: ${content}` : content}; + }, [conversation.latest_message, conversation.participants, classes.description]); return ( = (props) => { onSelectionChange={() => onSelect?.(conversation)} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - floatingAction={isHovered ? action : undefined} + floatingAction={showActions ? action : undefined} > - + ); }; diff --git a/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx b/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx index 191c238e..e7dc4953 100644 --- a/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx +++ b/workbench-app/src/components/FrontDoor/Controls/ConversationList.tsx @@ -1,7 +1,24 @@ // Copyright (c) Microsoft. All rights reserved. -import { Button, Input, Select, Text, makeStyles, shorthands, tokens } from '@fluentui/react-components'; -import { DismissRegular, FilterRegular } from '@fluentui/react-icons'; +import { + Button, + Checkbox, + Input, + Link, + Select, + Text, + Tooltip, + makeStyles, + shorthands, + tokens, +} from '@fluentui/react-components'; +import { + DismissRegular, + FilterRegular, + PinOffRegular, + PinRegular, + PlugDisconnectedRegular, +} from '@fluentui/react-icons'; import { EventSourceMessage } from '@microsoft/fetch-event-source'; import React from 'react'; @@ -45,6 +62,11 @@ const useClasses = makeStyles({ active: { backgroundColor: tokens.colorBrandBackgroundInvertedSelected, }, + bulkActions: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, }); export const ConversationList: React.FC = () => { @@ -52,7 +74,8 @@ export const ConversationList: React.FC = () => { const { getUserId } = useLocalUserAccount(); const environment = useEnvironment(); const { activeConversationId } = useAppSelector((state) => state.app); - const { navigateToConversation, isPinned } = useConversationUtility(); + const { navigateToConversation, hasUnreadMessages, markAllAsRead, markAsUnread, isPinned, setPinned } = + useConversationUtility(); const { data: conversations, error: conversationsError, @@ -67,6 +90,7 @@ export const ConversationList: React.FC = () => { const [duplicateConversation, setDuplicateConversation] = React.useState(); const [shareConversation, setShareConversation] = React.useState(); const [removeConversation, setRemoveConversation] = React.useState(); + const [selectedForActions, setSelectedForActions] = React.useState(new Set()); if (conversationsError) { const errorMessage = JSON.stringify(conversationsError); @@ -136,6 +160,72 @@ export const ConversationList: React.FC = () => { return dateB.getTime() - dateA.getTime(); }; + const handleSelectedForActions = (conversationId: string, selected: boolean) => { + if (selected) { + setSelectedForActions((prev) => new Set(prev).add(conversationId)); + } else { + setSelectedForActions((prev) => { + const newSet = new Set(prev); + newSet.delete(conversationId); + return newSet; + }); + } + }; + + const getSelectedConversations = () => { + return conversations?.filter((conversation) => selectedForActions.has(conversation.id)) ?? []; + }; + + const handleMarkAllAsReadForSelected = async () => { + await markAllAsRead(getSelectedConversations()); + setSelectedForActions(new Set()); + }; + + const handleMarkAsUnreadForSelected = async () => { + await markAsUnread(getSelectedConversations()); + setSelectedForActions(new Set()); + }; + + const handleRemoveForSelected = async () => { + // TODO: implement remove conversation + setSelectedForActions(new Set()); + }; + + const handlePinForSelected = async () => { + await setPinned(getSelectedConversations(), true); + setSelectedForActions(new Set()); + }; + + const handleUnpinForSelected = async () => { + await setPinned(getSelectedConversations(), false); + setSelectedForActions(new Set()); + }; + + const enableBulkActions = + selectedForActions.size > 0 + ? { + read: conversations?.some( + (conversation) => selectedForActions.has(conversation.id) && hasUnreadMessages(conversation), + ), + unread: conversations?.some( + (conversation) => selectedForActions.has(conversation.id) && !hasUnreadMessages(conversation), + ), + remove: true, + pin: conversations?.some( + (conversation) => selectedForActions.has(conversation.id) && !isPinned(conversation), + ), + unpin: conversations?.some( + (conversation) => selectedForActions.has(conversation.id) && isPinned(conversation), + ), + } + : { + read: false, + unread: false, + remove: false, + pin: false, + unpin: false, + }; + const conversationsToItems = (conversationList: Conversation[]) => { const splitByPinned: Record = { pinned: [], unpinned: [] }; conversationList.forEach((conversation) => { @@ -163,7 +253,10 @@ export const ConversationList: React.FC = () => { conversation={conversation} owned={conversation.ownerId === userId} selected={activeConversationId === conversation.id} + selectedForActions={selectedForActions?.has(conversation.id)} onSelect={() => navigateToConversation(conversation.id)} + showSelectForActions={selectedForActions.size > 0} + onSelectForActions={(_, selected) => handleSelectedForActions(conversation.id, selected)} onExport={() => exportConversation(conversation.id)} onRename={setRenameConversation} onDuplicate={setDuplicateConversation} @@ -229,6 +322,52 @@ export const ConversationList: React.FC = () => { +
+ 0} + onChange={(_event, data) => { + setSelectedForActions( + data.checked + ? new Set(myConversations.map((conversation) => conversation.id)) + : new Set(), + ); + }} + /> + + Read + +  |  + + Unread + +  |  + +
{myConversations.length === 0 && No Conversations found.} diff --git a/workbench-app/src/libs/useConversationUtility.ts b/workbench-app/src/libs/useConversationUtility.ts index afca0e60..5fb12406 100644 --- a/workbench-app/src/libs/useConversationUtility.ts +++ b/workbench-app/src/libs/useConversationUtility.ts @@ -174,9 +174,52 @@ export const useConversationUtility = () => { [getLastReadTimestamp], ); + const markAllAsRead = React.useCallback( + async (conversation: Conversation | Conversation[]) => { + const markSingleConversation = async (c: Conversation) => { + if (!hasUnreadMessages(c)) { + return; + } + await setAppMetadata(c, { lastReadTimestamp: getLastMessageTimestamp(c) }); + }; + + if (Array.isArray(conversation)) { + await Promise.all(conversation.map(markSingleConversation)); + return; + } + await markSingleConversation(conversation); + }, + [hasUnreadMessages, setAppMetadata, getLastMessageTimestamp], + ); + + const markAsUnread = React.useCallback( + async (conversation: Conversation | Conversation[]) => { + const markSingleConversation = async (c: Conversation) => { + if (hasUnreadMessages(c)) { + return; + } + await setAppMetadata(c, { lastReadTimestamp: undefined }); + }; + + if (Array.isArray(conversation)) { + await Promise.all(conversation.map(markSingleConversation)); + return; + } + + await markSingleConversation(conversation); + }, + [hasUnreadMessages, setAppMetadata], + ); + const setLastRead = React.useCallback( - async (conversation: Conversation, messageTimestamp: string) => { + async (conversation: Conversation | Conversation[], messageTimestamp: string) => { const debouncedFunction = Utility.debounce(async () => { + if (Array.isArray(conversation)) { + await Promise.all( + conversation.map((c) => setAppMetadata(c, { lastReadTimestamp: messageTimestamp })), + ); + return; + } await setAppMetadata(conversation, { lastReadTimestamp: messageTimestamp }); }, 300); @@ -199,7 +242,11 @@ export const useConversationUtility = () => { ); const setPinned = React.useCallback( - async (conversation: Conversation, pinned: boolean) => { + async (conversation: Conversation | Conversation[], pinned: boolean) => { + if (Array.isArray(conversation)) { + await Promise.all(conversation.map((c) => setAppMetadata(c, { pinned }))); + return; + } await setAppMetadata(conversation, { pinned }); }, [setAppMetadata], @@ -218,6 +265,8 @@ export const useConversationUtility = () => { isMessageVisible, hasUnreadMessages, isUnread, + markAllAsRead, + markAsUnread, setLastRead, isPinned, setPinned,