diff --git a/workspaces/lightspeed/.changeset/hot-bottles-lie.md b/workspaces/lightspeed/.changeset/hot-bottles-lie.md new file mode 100644 index 0000000000..79906bf7a1 --- /dev/null +++ b/workspaces/lightspeed/.changeset/hot-bottles-lie.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed': minor +--- + +feat: add conversation sorting with persistence, persisting pinned chats and pinned chats toggle per-user diff --git a/workspaces/lightspeed/packages/app/e2e-tests/lightspeed.test.ts b/workspaces/lightspeed/packages/app/e2e-tests/lightspeed.test.ts index 968daf3620..2996530440 100644 --- a/workspaces/lightspeed/packages/app/e2e-tests/lightspeed.test.ts +++ b/workspaces/lightspeed/packages/app/e2e-tests/lightspeed.test.ts @@ -79,6 +79,12 @@ import { verifyNoResultsFoundMessage, verifyChatUnpinned, clearSearch, + openSortDropdown, + verifySortDropdownOptions, + selectSortOption, + verifySortDropdownVisible, + closeSortDropdown, + verifyConversationsSortedAlphabetically, } from './utils/chatManagement'; import { login } from './utils/login'; import { @@ -410,6 +416,7 @@ test.describe('Lightspeed tests', () => { : test.describe.skip; chatManagementDescribeFn('Chat Management', () => { const testChatName = 'Test Rename'; + const deleteChatName = 'Conversation 1'; test('Verify chat actions menu', async () => { await sharedPage.reload(); @@ -431,20 +438,30 @@ test.describe('Lightspeed tests', () => { await selectPinAction(sharedPage, translations); await verifyChatPinned(sharedPage, testChatName); await verifyPinnedChatsNotEmpty(sharedPage, translations); + await sharedPage.reload(); + await verifyPinnedChatsNotEmpty(sharedPage, translations); }); test('Verify delete chat and its actions', async () => { - await verifyChatRenamed(sharedPage, testChatName); - await openChatContextMenuByName(sharedPage, testChatName, translations); + await verifyChatRenamed(sharedPage, deleteChatName); + await openChatContextMenuByName( + sharedPage, + deleteChatName, + translations, + ); await selectDeleteAction(sharedPage, translations); await verifyDeleteConfirmation(sharedPage, translations); await cancelChatDeletion(sharedPage, translations); - await verifyChatRenamed(sharedPage, testChatName); + await verifyChatRenamed(sharedPage, deleteChatName); - await openChatContextMenuByName(sharedPage, testChatName, translations); + await openChatContextMenuByName( + sharedPage, + deleteChatName, + translations, + ); await selectDeleteAction(sharedPage, translations); await confirmChatDeletion(sharedPage, translations); - await verifyChatDeleted(sharedPage, testChatName); + await verifyChatDeleted(sharedPage, deleteChatName); }); test('Verify disable pinned chats section via settings', async () => { @@ -490,6 +507,37 @@ test.describe('Lightspeed tests', () => { await selectUnpinAction(sharedPage, translations); await verifyChatUnpinned(sharedPage, translations); }); + + test('Verify sort dropdown is available', async () => { + await verifySortDropdownVisible(sharedPage, translations); + await openSortDropdown(sharedPage, translations); + await verifySortDropdownOptions(sharedPage, translations); + await closeSortDropdown(sharedPage); + }); + + test('Verify conversations are sorted correctly', async () => { + // Verify alphabetical ascending sort (A-Z) + await openSortDropdown(sharedPage, translations); + await selectSortOption(sharedPage, 'alphabeticalAsc', translations); + await verifyConversationsSortedAlphabetically( + sharedPage, + translations, + 'asc', + ); + + // Verify alphabetical descending sort (Z-A) + await openSortDropdown(sharedPage, translations); + await selectSortOption(sharedPage, 'alphabeticalDesc', translations); + await verifyConversationsSortedAlphabetically( + sharedPage, + translations, + 'desc', + ); + + // Reset to newest first + await openSortDropdown(sharedPage, translations); + await selectSortOption(sharedPage, 'newest', translations); + }); }); }); }); diff --git a/workspaces/lightspeed/packages/app/e2e-tests/utils/chatManagement.ts b/workspaces/lightspeed/packages/app/e2e-tests/utils/chatManagement.ts index 6e57740239..417490d86f 100644 --- a/workspaces/lightspeed/packages/app/e2e-tests/utils/chatManagement.ts +++ b/workspaces/lightspeed/packages/app/e2e-tests/utils/chatManagement.ts @@ -362,3 +362,96 @@ export const verifyChatUnpinned = async ( .filter({ hasText: translations['chatbox.emptyState.noPinnedChats'] }), ).toBeVisible(); }; + +export type SortOption = + | 'newest' + | 'oldest' + | 'alphabeticalAsc' + | 'alphabeticalDesc'; + +export const openSortDropdown = async ( + page: Page, + translations: LightspeedMessages, +) => { + await page.getByRole('button', { name: translations['sort.label'] }).click(); +}; + +export const verifySortDropdownOptions = async ( + page: Page, + translations: LightspeedMessages, +) => { + await expect(page.locator('#sort-select')).toMatchAriaSnapshot(` + - listbox: + - option "${translations['sort.newest']}" + - option "${translations['sort.oldest']}" + - option "${translations['sort.alphabeticalAsc']}" + - option "${translations['sort.alphabeticalDesc']}" + `); +}; + +export const selectSortOption = async ( + page: Page, + sortOption: SortOption, + translations: LightspeedMessages, +) => { + const sortOptionLabels: Record = { + newest: 'sort.newest', + oldest: 'sort.oldest', + alphabeticalAsc: 'sort.alphabeticalAsc', + alphabeticalDesc: 'sort.alphabeticalDesc', + }; + await page + .getByRole('option', { name: translations[sortOptionLabels[sortOption]] }) + .click(); +}; + +export const getConversationNames = async ( + page: Page, + translations: LightspeedMessages, +): Promise => { + const sidePanel = page.locator('.pf-v6-c-drawer__panel-main'); + const recentSection = sidePanel.locator( + `ul[aria-label="${translations['conversation.category.recent']}"] li.pf-chatbot__menu-item`, + ); + + const chatItems = await recentSection.all(); + const names: string[] = []; + + for (const item of chatItems) { + const text = await item.textContent(); + if (text) { + names.push(text.trim()); + } + } + + return names; +}; + +export const verifyConversationsSortedAlphabetically = async ( + page: Page, + translations: LightspeedMessages, + order: 'asc' | 'desc' = 'asc', +) => { + const conversationNames = await getConversationNames(page, translations); + + const sortedNames = [...conversationNames].sort((a, b) => + order === 'asc' + ? a.localeCompare(b, undefined, { sensitivity: 'base' }) + : b.localeCompare(a, undefined, { sensitivity: 'base' }), + ); + + expect(conversationNames).toEqual(sortedNames); +}; + +export const verifySortDropdownVisible = async ( + page: Page, + translations: LightspeedMessages, +) => { + await expect( + page.getByRole('button', { name: translations['sort.label'] }), + ).toBeVisible(); +}; + +export const closeSortDropdown = async (page: Page) => { + await page.keyboard.press('Escape'); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/package.json b/workspaces/lightspeed/plugins/lightspeed/package.json index 07a2f2960c..b67487afd8 100644 --- a/workspaces/lightspeed/plugins/lightspeed/package.json +++ b/workspaces/lightspeed/plugins/lightspeed/package.json @@ -55,13 +55,15 @@ "@backstage/theme": "^0.7.0", "@material-ui/core": "^4.9.13", "@material-ui/lab": "^4.0.0-alpha.61", + "@monaco-editor/react": "^4.7.0", "@mui/icons-material": "^6.1.8", "@mui/material": "^5.12.2", - "@patternfly/chatbot": "6.4.1", + "@patternfly/chatbot": "6.5.0-prerelease.28", "@patternfly/react-core": "6.4.0", "@patternfly/react-icons": "^6.3.1", "@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^", "@tanstack/react-query": "^5.59.15", + "monaco-editor": "^0.54.0", "react-markdown": "^9.0.1", "react-use": "^17.2.4" }, diff --git a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md index 6108a96832..14258d738d 100644 --- a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md +++ b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md @@ -135,6 +135,11 @@ readonly "settings.pinned.enable": string; readonly "settings.pinned.disable": string; readonly "settings.pinned.enabled.description": string; readonly "settings.pinned.disabled.description": string; +readonly "sort.label": string; +readonly "sort.newest": string; +readonly "sort.oldest": string; +readonly "sort.alphabeticalAsc": string; +readonly "sort.alphabeticalDesc": string; }>; // @alpha diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index d675b1db5c..055cc73774 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -35,8 +35,23 @@ import { MessageProps, } from '@patternfly/chatbot'; import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; -import { DropdownItem, DropEvent, Title } from '@patternfly/react-core'; -import { PlusIcon, SearchIcon } from '@patternfly/react-icons'; +import { + DropdownItem, + DropEvent, + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + Title, + Tooltip, +} from '@patternfly/react-core'; +import { + PlusIcon, + SearchIcon, + SortAmountDownAltIcon, + SortAmountDownIcon, +} from '@patternfly/react-icons'; import { useQueryClient } from '@tanstack/react-query'; import { supportedFileTypes, TEMP_CONVERSATION_ID } from '../const'; @@ -47,6 +62,7 @@ import { useIsMobile, useLastOpenedConversation, useLightspeedDeletePermission, + usePinnedChatsSettings, } from '../hooks'; import { useLightspeedUpdatePermission } from '../hooks/useLightspeedUpdatePermission'; import { useTranslation } from '../hooks/useTranslation'; @@ -56,6 +72,7 @@ import { getAttachments } from '../utils/attachment-utils'; import { getCategorizeMessages, getFootnoteProps, + SortOption, } from '../utils/lightspeed-chatbox-utils'; import Attachment from './Attachment'; import { useFileAttachmentContext } from './AttachmentContext'; @@ -100,6 +117,10 @@ const useStyles = makeStyles(theme => ({ maxWidth: '100%', }, }, + sortDropdown: { + padding: 0, + margin: 0, + }, })); type LightspeedChatProps = { @@ -134,14 +155,23 @@ export const LightspeedChat = ({ const [newChatCreated, setNewChatCreated] = useState(false); const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false); - const [isPinningChatsEnabled, setIsPinningChatsEnabled] = useState(true); // read from user settings in future - const [pinnedChats, setPinnedChats] = useState([]); // read from user settings in future const [targetConversationId, setTargetConversationId] = useState(''); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); + const [isSortSelectOpen, setIsSortSelectOpen] = useState(false); const { isReady, lastOpenedId, setLastOpenedId, clearLastOpenedId } = useLastOpenedConversation(user); + const { + isPinningChatsEnabled, + pinnedChats, + selectedSort, + handlePinningChatsToggle, + pinChat, + unpinChat, + handleSortChange, + } = usePinnedChatsSettings(user); + const { uploadError, showAlert, @@ -159,12 +189,6 @@ export const LightspeedChat = ({ } }, [lastOpenedId, isReady]); - useEffect(() => { - if (!isPinningChatsEnabled) { - setPinnedChats([]); - } - }, [isPinningChatsEnabled]); - const queryClient = useQueryClient(); const { @@ -282,14 +306,6 @@ export const LightspeedChat = ({ setIsDeleteModalOpen(false); }, [clearLastOpenedId, lastOpenedId, onNewChat, targetConversationId]); - const pinChat = (convId: string) => { - setPinnedChats(prev => [...prev, convId]); // write to user settings in future - }; - - const unpinChat = (convId: string) => { - setPinnedChats(prev => prev.filter(id => id !== convId)); // write to user settings in future - }; - const additionalMessageProps = useCallback( (conversationSummary: ConversationSummary) => { const isChatFavorite = pinnedChats?.find( @@ -337,7 +353,15 @@ export const LightspeedChat = ({ ), }; }, - [pinnedChats, hasDeleteAccess, isPinningChatsEnabled, hasUpdateAccess, t], + [ + pinnedChats, + hasDeleteAccess, + isPinningChatsEnabled, + hasUpdateAccess, + t, + pinChat, + unpinChat, + ], ); const categorizedMessages = useMemo( @@ -347,8 +371,9 @@ export const LightspeedChat = ({ pinnedChats, additionalMessageProps, t, + selectedSort, ), - [additionalMessageProps, conversations, pinnedChats, t], + [additionalMessageProps, conversations, pinnedChats, t, selectedSort], ); const filterConversations = useCallback( @@ -469,6 +494,86 @@ export const LightspeedChat = ({ setIsDrawerOpen(isOpen => !isOpen); }, []); + const onSortToggle = useCallback(() => { + setIsSortSelectOpen(prev => !prev); + }, []); + + const onSortSelect = useCallback( + (_event?: React.MouseEvent, value?: string | number) => { + handleSortChange(value as SortOption); + setIsSortSelectOpen(false); + }, + [handleSortChange], + ); + + const getSortLabel = useCallback( + (option: SortOption): string => { + const labels: Record = { + newest: t('sort.newest'), + oldest: t('sort.oldest'), + alphabeticalAsc: t('sort.alphabeticalAsc'), + alphabeticalDesc: t('sort.alphabeticalDesc'), + }; + return labels[option]; + }, + [t], + ); + + const sortToggle = useCallback( + (toggleRef: React.Ref) => ( + + + {selectedSort === 'oldest' || selectedSort === 'alphabeticalDesc' ? ( + + ) : ( + + )} + + + ), + [t, getSortLabel, selectedSort, onSortToggle, isSortSelectOpen], + ); + + const sortDropdown = useMemo( + () => ( + + ), + [ + isSortSelectOpen, + selectedSort, + onSortSelect, + sortToggle, + t, + classes.sortDropdown, + ], + ); + const handleAttach = (data: File[], event: DropEvent) => { event.preventDefault(); handleFileUpload(data); @@ -527,7 +632,7 @@ export const LightspeedChat = ({ handleSelectedModel={item => handleSelectedModel(item)} models={models} isPinningChatsEnabled={isPinningChatsEnabled} - onPinnedChatsToggle={setIsPinningChatsEnabled} + onPinnedChatsToggle={handlePinningChatsToggle} /> @@ -560,6 +665,7 @@ export const LightspeedChat = ({ setFilterValue(''); }, }} + searchActionEnd={sortDropdown} noResultsState={ filterValue && Object.keys(filterConversations(filterValue)).length === 0 diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx index aaf24c1fa2..3d85da971a 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx @@ -88,6 +88,18 @@ jest.mock('../../hooks/useTranslation', () => ({ useTranslation: jest.fn(() => mockUseTranslation()), })); +jest.mock('../../hooks/usePinnedChatsSettings', () => ({ + usePinnedChatsSettings: jest.fn().mockReturnValue({ + isPinningChatsEnabled: true, + pinnedChats: [], + selectedSort: 'newest', + handlePinningChatsToggle: jest.fn(), + pinChat: jest.fn(), + unpinChat: jest.fn(), + handleSortChange: jest.fn(), + }), +})); + jest.mock('@patternfly/chatbot', () => { const actual = jest.requireActual('@patternfly/chatbot'); return { diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/usePinnedChatsSettings.test.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/usePinnedChatsSettings.test.ts new file mode 100644 index 0000000000..c9cdd80af5 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/usePinnedChatsSettings.test.ts @@ -0,0 +1,497 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createElement, ReactNode } from 'react'; + +import { storageApiRef } from '@backstage/core-plugin-api'; +import { MockStorageApi, TestApiProvider } from '@backstage/test-utils'; + +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { usePinnedChatsSettings } from '../usePinnedChatsSettings'; + +describe('usePinnedChatsSettings', () => { + const mockUser = 'user:default/guest'; + const anotherUser = 'user:default/another'; + let mockStorageApi: MockStorageApi; + + const createWrapper = (storageApi: MockStorageApi) => { + return ({ children }: { children: ReactNode }) => + createElement(TestApiProvider, { + apis: [[storageApiRef, storageApi]], + children, + }); + }; + + beforeEach(() => { + mockStorageApi = MockStorageApi.create(); + }); + + describe('initialization', () => { + it('should initialize with default values when user is undefined', () => { + const { result } = renderHook(() => usePinnedChatsSettings(undefined), { + wrapper: createWrapper(mockStorageApi), + }); + + expect(result.current.isPinningChatsEnabled).toBe(true); + expect(result.current.pinnedChats).toEqual([]); + expect(result.current.selectedSort).toBe('newest'); + }); + + it('should initialize with default values for a new user', () => { + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + expect(result.current.isPinningChatsEnabled).toBe(true); + expect(result.current.pinnedChats).toEqual([]); + expect(result.current.selectedSort).toBe('newest'); + }); + + it('should load persisted values from storage for existing user', async () => { + // Pre-populate storage with user data + const bucket = mockStorageApi.forBucket('lightspeed'); + bucket.set('pinnedChatsEnabled', { [mockUser]: false }); + bucket.set('pinnedChats', { [mockUser]: ['conv-1', 'conv-2'] }); + bucket.set('sortOrder', { [mockUser]: 'oldest' }); + + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + await waitFor(() => { + expect(result.current.isPinningChatsEnabled).toBe(false); + expect(result.current.pinnedChats).toEqual(['conv-1', 'conv-2']); + expect(result.current.selectedSort).toBe('oldest'); + }); + }); + + it('should scope settings per user', async () => { + const bucket = mockStorageApi.forBucket('lightspeed'); + bucket.set('pinnedChatsEnabled', { + [mockUser]: true, + [anotherUser]: false, + }); + bucket.set('pinnedChats', { + [mockUser]: ['conv-1'], + [anotherUser]: ['conv-2', 'conv-3'], + }); + bucket.set('sortOrder', { + [mockUser]: 'newest', + [anotherUser]: 'alphabeticalAsc', + }); + + const { result: result1 } = renderHook( + () => usePinnedChatsSettings(mockUser), + { + wrapper: createWrapper(mockStorageApi), + }, + ); + + const { result: result2 } = renderHook( + () => usePinnedChatsSettings(anotherUser), + { + wrapper: createWrapper(mockStorageApi), + }, + ); + + await waitFor(() => { + expect(result1.current.isPinningChatsEnabled).toBe(true); + expect(result1.current.pinnedChats).toEqual(['conv-1']); + expect(result1.current.selectedSort).toBe('newest'); + + expect(result2.current.isPinningChatsEnabled).toBe(false); + expect(result2.current.pinnedChats).toEqual(['conv-2', 'conv-3']); + expect(result2.current.selectedSort).toBe('alphabeticalAsc'); + }); + }); + }); + + describe('handlePinningChatsToggle', () => { + it('should toggle pinning chats enabled state', async () => { + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + expect(result.current.isPinningChatsEnabled).toBe(true); + + act(() => { + result.current.handlePinningChatsToggle(false); + }); + + await waitFor(() => { + expect(result.current.isPinningChatsEnabled).toBe(false); + }); + + act(() => { + result.current.handlePinningChatsToggle(true); + }); + + await waitFor(() => { + expect(result.current.isPinningChatsEnabled).toBe(true); + }); + }); + + it('should persist toggle state to storage', async () => { + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + act(() => { + result.current.handlePinningChatsToggle(false); + }); + + await waitFor(() => { + const bucket = mockStorageApi.forBucket('lightspeed'); + const snapshot = bucket.snapshot<{ [key: string]: boolean }>( + 'pinnedChatsEnabled', + ); + expect(snapshot.value?.[mockUser]).toBe(false); + }); + }); + + it('should clear pinned chats when disabling pinning', async () => { + const bucket = mockStorageApi.forBucket('lightspeed'); + bucket.set('pinnedChats', { [mockUser]: ['conv-1', 'conv-2'] }); + + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + await waitFor(() => { + expect(result.current.pinnedChats).toEqual(['conv-1', 'conv-2']); + }); + + act(() => { + result.current.handlePinningChatsToggle(false); + }); + + await waitFor(() => { + expect(result.current.pinnedChats).toEqual([]); + }); + }); + + it('should not update if user is undefined', () => { + const { result } = renderHook(() => usePinnedChatsSettings(undefined), { + wrapper: createWrapper(mockStorageApi), + }); + + act(() => { + result.current.handlePinningChatsToggle(false); + }); + + // Should remain at default value + expect(result.current.isPinningChatsEnabled).toBe(true); + }); + }); + + describe('pinChat', () => { + it('should add a chat to pinned chats', async () => { + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + expect(result.current.pinnedChats).toEqual([]); + + act(() => { + result.current.pinChat('conv-1'); + }); + + await waitFor(() => { + expect(result.current.pinnedChats).toEqual(['conv-1']); + }); + }); + + it('should add multiple chats to pinned chats', async () => { + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + act(() => { + result.current.pinChat('conv-1'); + }); + + await waitFor(() => { + expect(result.current.pinnedChats).toEqual(['conv-1']); + }); + + act(() => { + result.current.pinChat('conv-2'); + }); + + await waitFor(() => { + expect(result.current.pinnedChats).toEqual(['conv-1', 'conv-2']); + }); + }); + + it('should persist pinned chats to storage', async () => { + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + act(() => { + result.current.pinChat('conv-1'); + }); + + await waitFor(() => { + const bucket = mockStorageApi.forBucket('lightspeed'); + const snapshot = bucket.snapshot<{ [key: string]: string[] }>( + 'pinnedChats', + ); + expect(snapshot.value?.[mockUser]).toEqual(['conv-1']); + }); + }); + + it('should not update if user is undefined', () => { + const { result } = renderHook(() => usePinnedChatsSettings(undefined), { + wrapper: createWrapper(mockStorageApi), + }); + + act(() => { + result.current.pinChat('conv-1'); + }); + + expect(result.current.pinnedChats).toEqual([]); + }); + }); + + describe('unpinChat', () => { + it('should remove a chat from pinned chats', async () => { + const bucket = mockStorageApi.forBucket('lightspeed'); + bucket.set('pinnedChats', { [mockUser]: ['conv-1', 'conv-2'] }); + + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + await waitFor(() => { + expect(result.current.pinnedChats).toEqual(['conv-1', 'conv-2']); + }); + + act(() => { + result.current.unpinChat('conv-1'); + }); + + await waitFor(() => { + expect(result.current.pinnedChats).toEqual(['conv-2']); + }); + }); + + it('should persist unpinned chats to storage', async () => { + const bucket = mockStorageApi.forBucket('lightspeed'); + bucket.set('pinnedChats', { [mockUser]: ['conv-1', 'conv-2'] }); + + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + await waitFor(() => { + expect(result.current.pinnedChats).toEqual(['conv-1', 'conv-2']); + }); + + act(() => { + result.current.unpinChat('conv-1'); + }); + + await waitFor(() => { + const snapshot = bucket.snapshot<{ [key: string]: string[] }>( + 'pinnedChats', + ); + expect(snapshot.value?.[mockUser]).toEqual(['conv-2']); + }); + }); + + it('should handle unpinning non-existent chat gracefully', async () => { + const bucket = mockStorageApi.forBucket('lightspeed'); + bucket.set('pinnedChats', { [mockUser]: ['conv-1'] }); + + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + await waitFor(() => { + expect(result.current.pinnedChats).toEqual(['conv-1']); + }); + + act(() => { + result.current.unpinChat('non-existent'); + }); + + await waitFor(() => { + expect(result.current.pinnedChats).toEqual(['conv-1']); + }); + }); + + it('should not update if user is undefined', async () => { + const bucket = mockStorageApi.forBucket('lightspeed'); + bucket.set('pinnedChats', { [mockUser]: ['conv-1'] }); + + const { result } = renderHook(() => usePinnedChatsSettings(undefined), { + wrapper: createWrapper(mockStorageApi), + }); + + act(() => { + result.current.unpinChat('conv-1'); + }); + + expect(result.current.pinnedChats).toEqual([]); + }); + }); + + describe('handleSortChange', () => { + it('should change sort order to oldest', async () => { + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + expect(result.current.selectedSort).toBe('newest'); + + act(() => { + result.current.handleSortChange('oldest'); + }); + + await waitFor(() => { + expect(result.current.selectedSort).toBe('oldest'); + }); + }); + + it('should change sort order to alphabeticalAsc', async () => { + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + act(() => { + result.current.handleSortChange('alphabeticalAsc'); + }); + + await waitFor(() => { + expect(result.current.selectedSort).toBe('alphabeticalAsc'); + }); + }); + + it('should change sort order to alphabeticalDesc', async () => { + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + act(() => { + result.current.handleSortChange('alphabeticalDesc'); + }); + + await waitFor(() => { + expect(result.current.selectedSort).toBe('alphabeticalDesc'); + }); + }); + + it('should persist sort order to storage', async () => { + const { result } = renderHook(() => usePinnedChatsSettings(mockUser), { + wrapper: createWrapper(mockStorageApi), + }); + + act(() => { + result.current.handleSortChange('oldest'); + }); + + await waitFor(() => { + const bucket = mockStorageApi.forBucket('lightspeed'); + const snapshot = bucket.snapshot<{ [key: string]: string }>( + 'sortOrder', + ); + expect(snapshot.value?.[mockUser]).toBe('oldest'); + }); + }); + + it('should not update if user is undefined', () => { + const { result } = renderHook(() => usePinnedChatsSettings(undefined), { + wrapper: createWrapper(mockStorageApi), + }); + + act(() => { + result.current.handleSortChange('oldest'); + }); + + expect(result.current.selectedSort).toBe('newest'); + }); + }); + + describe('user change behavior', () => { + it('should reset to defaults when user becomes undefined', async () => { + const bucket = mockStorageApi.forBucket('lightspeed'); + bucket.set('pinnedChatsEnabled', { [mockUser]: false }); + bucket.set('pinnedChats', { [mockUser]: ['conv-1'] }); + bucket.set('sortOrder', { [mockUser]: 'oldest' }); + + const { result, rerender } = renderHook( + ({ user }) => usePinnedChatsSettings(user), + { + wrapper: createWrapper(mockStorageApi), + initialProps: { user: mockUser as string | undefined }, + }, + ); + + await waitFor(() => { + expect(result.current.isPinningChatsEnabled).toBe(false); + expect(result.current.pinnedChats).toEqual(['conv-1']); + expect(result.current.selectedSort).toBe('oldest'); + }); + + rerender({ user: undefined }); + + await waitFor(() => { + expect(result.current.isPinningChatsEnabled).toBe(true); + expect(result.current.pinnedChats).toEqual([]); + expect(result.current.selectedSort).toBe('newest'); + }); + }); + + it('should load different user settings when user changes', async () => { + const bucket = mockStorageApi.forBucket('lightspeed'); + bucket.set('pinnedChatsEnabled', { + [mockUser]: true, + [anotherUser]: false, + }); + bucket.set('pinnedChats', { + [mockUser]: ['conv-1'], + [anotherUser]: ['conv-2', 'conv-3'], + }); + bucket.set('sortOrder', { + [mockUser]: 'newest', + [anotherUser]: 'alphabeticalDesc', + }); + + const { result, rerender } = renderHook( + ({ user }) => usePinnedChatsSettings(user), + { + wrapper: createWrapper(mockStorageApi), + initialProps: { user: mockUser }, + }, + ); + + await waitFor(() => { + expect(result.current.isPinningChatsEnabled).toBe(true); + expect(result.current.pinnedChats).toEqual(['conv-1']); + expect(result.current.selectedSort).toBe('newest'); + }); + + rerender({ user: anotherUser }); + + await waitFor(() => { + expect(result.current.isPinningChatsEnabled).toBe(false); + expect(result.current.pinnedChats).toEqual(['conv-2', 'conv-3']); + expect(result.current.selectedSort).toBe('alphabeticalDesc'); + }); + }); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/index.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/index.ts index 53da33fe59..927f91292d 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/index.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/index.ts @@ -23,4 +23,5 @@ export * from './useIsMobile'; export * from './useLastOpenedConversation'; export * from './useLightspeedDeletePermission'; export * from './useLightspeedViewPermission'; +export * from './usePinnedChatsSettings'; export * from './useTranslation'; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/usePinnedChatsSettings.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/usePinnedChatsSettings.ts new file mode 100644 index 0000000000..07434047cc --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/usePinnedChatsSettings.ts @@ -0,0 +1,199 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useCallback, useEffect, useState } from 'react'; + +import { storageApiRef, useApi } from '@backstage/core-plugin-api'; + +import { SortOption } from '../utils/lightspeed-chatbox-utils'; + +const BUCKET_NAME = 'lightspeed'; +const PINNED_ENABLED_KEY = 'pinnedChatsEnabled'; +const PINNED_CHATS_KEY = 'pinnedChats'; +const SORT_ORDER_KEY = 'sortOrder'; + +type UserSettings = { + [userId: string]: boolean | string[] | SortOption; +}; + +type UsePinnedChatsSettingsReturn = { + isPinningChatsEnabled: boolean; + pinnedChats: string[]; + selectedSort: SortOption; + handlePinningChatsToggle: (enabled: boolean) => void; + pinChat: (convId: string) => void; + unpinChat: (convId: string) => void; + handleSortChange: (sortOption: SortOption) => void; +}; + +/** + * Hook to manage pinned chats settings with persistence using Backstage StorageApi. + * Settings are scoped per-user to support multi-user environments. + * + * @param user - The user entity ref (e.g., "user:default/guest") + * @returns Object containing pinned chats state and management functions + */ +export const usePinnedChatsSettings = ( + user: string | undefined, +): UsePinnedChatsSettingsReturn => { + const storageApi = useApi(storageApiRef); + const bucket = storageApi.forBucket(BUCKET_NAME); + + const [isPinningChatsEnabled, setIsPinningChatsEnabled] = useState(true); + const [pinnedChats, setPinnedChats] = useState([]); + const [selectedSort, setSelectedSort] = useState('newest'); + + // Initialize from storage on mount or when user changes + useEffect(() => { + if (!user) { + setIsPinningChatsEnabled(true); + setPinnedChats([]); + setSelectedSort('newest'); + return; + } + + try { + const enabledSnapshot = bucket.snapshot(PINNED_ENABLED_KEY); + const chatsSnapshot = bucket.snapshot(PINNED_CHATS_KEY); + const sortSnapshot = bucket.snapshot(SORT_ORDER_KEY); + + const enabledData = enabledSnapshot.value ?? {}; + const chatsData = chatsSnapshot.value ?? {}; + const sortData = sortSnapshot.value ?? {}; + + setIsPinningChatsEnabled( + (enabledData[user] as boolean | undefined) ?? true, + ); + setPinnedChats((chatsData[user] as string[] | undefined) ?? []); + setSelectedSort((sortData[user] as SortOption | undefined) ?? 'newest'); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error reading pinned chats settings from storage:', error); + } + }, [bucket, user]); + + // Toggle pinning feature on/off + const handlePinningChatsToggle = useCallback( + (enabled: boolean) => { + if (!user) return; + + setIsPinningChatsEnabled(enabled); + + try { + const enabledSnapshot = + bucket.snapshot(PINNED_ENABLED_KEY); + // Create a copy to avoid mutating the read-only snapshot + const enabledData = { ...enabledSnapshot.value }; + enabledData[user] = enabled; + bucket.set(PINNED_ENABLED_KEY, enabledData); + + // Clear pinned chats when disabling + if (!enabled) { + setPinnedChats([]); + const chatsSnapshot = bucket.snapshot(PINNED_CHATS_KEY); + // Create a copy to avoid mutating the read-only snapshot + const chatsData = { ...chatsSnapshot.value }; + chatsData[user] = []; + bucket.set(PINNED_CHATS_KEY, chatsData); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error saving pinning toggle state:', error); + } + }, + [bucket, user], + ); + + // Pin a chat + const pinChat = useCallback( + (convId: string) => { + if (!user) return; + + setPinnedChats(prev => { + const updated = [...prev, convId]; + + try { + const chatsSnapshot = bucket.snapshot(PINNED_CHATS_KEY); + // Create a copy to avoid mutating the read-only snapshot + const chatsData = { ...chatsSnapshot.value }; + chatsData[user] = updated; + bucket.set(PINNED_CHATS_KEY, chatsData); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error saving pinned chat:', error); + } + + return updated; + }); + }, + [bucket, user], + ); + + // Unpin a chat + const unpinChat = useCallback( + (convId: string) => { + if (!user) return; + + setPinnedChats(prev => { + const updated = prev.filter(id => id !== convId); + + try { + const chatsSnapshot = bucket.snapshot(PINNED_CHATS_KEY); + // Create a copy to avoid mutating the read-only snapshot + const chatsData = { ...chatsSnapshot.value }; + chatsData[user] = updated; + bucket.set(PINNED_CHATS_KEY, chatsData); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error saving unpinned chat:', error); + } + + return updated; + }); + }, + [bucket, user], + ); + + // Change sort order + const handleSortChange = useCallback( + (sortOption: SortOption) => { + if (!user) return; + + setSelectedSort(sortOption); + + try { + const sortSnapshot = bucket.snapshot(SORT_ORDER_KEY); + // Create a copy to avoid mutating the read-only snapshot + const sortData = { ...sortSnapshot.value }; + sortData[user] = sortOption; + bucket.set(SORT_ORDER_KEY, sortData); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error saving sort order:', error); + } + }, + [bucket, user], + ); + + return { + isPinningChatsEnabled, + pinnedChats, + selectedSort, + handlePinningChatsToggle, + pinChat, + unpinChat, + handleSortChange, + }; +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts index e3a9106c1e..3d5f811e3a 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts @@ -227,6 +227,13 @@ const lightspeedTranslationDe = createTranslationMessages({ 'Angeheftete Chats sind derzeit aktiviert', 'settings.pinned.disabled.description': 'Angeheftete Chats sind derzeit deaktiviert', + + // Sort options + 'sort.label': 'Konversationen sortieren', + 'sort.newest': 'Datum (neueste zuerst)', + 'sort.oldest': 'Datum (älteste zuerst)', + 'sort.alphabeticalAsc': 'Name (A-Z)', + 'sort.alphabeticalDesc': 'Name (Z-A)', }, }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts index 13a13a2abc..1820f22bb6 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts @@ -230,6 +230,13 @@ const lightspeedTranslationEs = createTranslationMessages({ 'Los chats fijados están actualmente habilitados', 'settings.pinned.disabled.description': 'Los chats fijados están actualmente deshabilitados', + + // Sort options + 'sort.label': 'Ordenar conversaciones', + 'sort.newest': 'Fecha (más reciente primero)', + 'sort.oldest': 'Fecha (más antiguo primero)', + 'sort.alphabeticalAsc': 'Nombre (A-Z)', + 'sort.alphabeticalDesc': 'Nombre (Z-A)', }, }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts index d2a15643aa..00488b49a3 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts @@ -231,6 +231,13 @@ const lightspeedTranslationFr = createTranslationMessages({ 'Les chats épinglés sont actuellement activés', 'settings.pinned.disabled.description': 'Les chats épinglés sont actuellement désactivés', + + // Sort options + 'sort.label': 'Trier les conversations', + 'sort.newest': 'Date (plus récent en premier)', + 'sort.oldest': 'Date (plus ancien en premier)', + 'sort.alphabeticalAsc': 'Nom (A-Z)', + 'sort.alphabeticalDesc': 'Nom (Z-A)', }, }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts index 486e8cdf1d..1c0e5dd768 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts @@ -218,6 +218,13 @@ export const lightspeedMessages = { 'settings.pinned.disable': 'Disable pinned chats', 'settings.pinned.enabled.description': 'Pinned chats are currently enabled', 'settings.pinned.disabled.description': 'Pinned chats are currently disabled', + + // Sort options + 'sort.label': 'Sort conversations', + 'sort.newest': 'Date (newest first)', + 'sort.oldest': 'Date (oldest first)', + 'sort.alphabeticalAsc': 'Name (A-Z)', + 'sort.alphabeticalDesc': 'Name (Z-A)', }; /** diff --git a/workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/lightspeed-chatbot-utils.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/lightspeed-chatbot-utils.test.tsx index f0a0363fc1..82bfd9b783 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/lightspeed-chatbot-utils.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/lightspeed-chatbot-utils.test.tsx @@ -26,6 +26,7 @@ import { getCategorizeMessages, getTimestamp, getTimestampVariablesString, + SortOption, splitJsonStrings, transformDocumentsToSources, } from '../lightspeed-chatbox-utils'; @@ -410,4 +411,215 @@ describe('getCategorizeMessages', () => { expect(result.Recientes).toBeDefined(); expect(result.Recientes[0].label).toBe('Opciones'); }); + + describe('sorting functionality', () => { + const sortTestMessages: ConversationList = [ + { + conversation_id: 'a', + last_message_timestamp: 1000, + topic_summary: 'Zeta Chat', + }, + { + conversation_id: 'b', + last_message_timestamp: 3000, + topic_summary: 'Alpha Chat', + }, + { + conversation_id: 'c', + last_message_timestamp: 2000, + topic_summary: 'Beta Chat', + }, + ]; + + it('sorts messages by newest first (default)', () => { + const result = getCategorizeMessages( + sortTestMessages, + [], + addProps, + undefined, + 'newest', + ); + + expect(result.Recent[0].id).toBe('b'); // timestamp 3000 + expect(result.Recent[1].id).toBe('c'); // timestamp 2000 + expect(result.Recent[2].id).toBe('a'); // timestamp 1000 + }); + + it('sorts messages by oldest first', () => { + const result = getCategorizeMessages( + sortTestMessages, + [], + addProps, + undefined, + 'oldest', + ); + + expect(result.Recent[0].id).toBe('a'); // timestamp 1000 + expect(result.Recent[1].id).toBe('c'); // timestamp 2000 + expect(result.Recent[2].id).toBe('b'); // timestamp 3000 + }); + + it('sorts messages alphabetically ascending (A-Z)', () => { + const result = getCategorizeMessages( + sortTestMessages, + [], + addProps, + undefined, + 'alphabeticalAsc', + ); + + expect(result.Recent[0].text).toBe('Alpha Chat'); + expect(result.Recent[1].text).toBe('Beta Chat'); + expect(result.Recent[2].text).toBe('Zeta Chat'); + }); + + it('sorts messages alphabetically descending (Z-A)', () => { + const result = getCategorizeMessages( + sortTestMessages, + [], + addProps, + undefined, + 'alphabeticalDesc', + ); + + expect(result.Recent[0].text).toBe('Zeta Chat'); + expect(result.Recent[1].text).toBe('Beta Chat'); + expect(result.Recent[2].text).toBe('Alpha Chat'); + }); + + it('applies sorting to both pinned and recent sections', () => { + const result = getCategorizeMessages( + sortTestMessages, + ['a', 'c'], + addProps, + undefined, + 'alphabeticalAsc', + ); + + // Pinned section should be sorted alphabetically + expect(result.Pinned[0].text).toBe('Beta Chat'); // 'c' + expect(result.Pinned[1].text).toBe('Zeta Chat'); // 'a' + + // Recent section should also be sorted + expect(result.Recent[0].text).toBe('Alpha Chat'); // 'b' + }); + + it('uses newest as default when sort option is not provided', () => { + const result = getCategorizeMessages(sortTestMessages, [], addProps); + + expect(result.Recent[0].id).toBe('b'); // timestamp 3000 (newest) + expect(result.Recent[2].id).toBe('a'); // timestamp 1000 (oldest) + }); + + it('handles case-insensitive alphabetical sorting', () => { + const mixedCaseMessages: ConversationList = [ + { + conversation_id: '1', + last_message_timestamp: 1000, + topic_summary: 'apple', + }, + { + conversation_id: '2', + last_message_timestamp: 2000, + topic_summary: 'Banana', + }, + { + conversation_id: '3', + last_message_timestamp: 3000, + topic_summary: 'CHERRY', + }, + ]; + + const result = getCategorizeMessages( + mixedCaseMessages, + [], + addProps, + undefined, + 'alphabeticalAsc', + ); + + expect(result.Recent[0].text).toBe('apple'); + expect(result.Recent[1].text).toBe('Banana'); + expect(result.Recent[2].text).toBe('CHERRY'); + }); + + it('handles empty messages array', () => { + const result = getCategorizeMessages( + [], + [], + addProps, + undefined, + 'newest', + ); + + expect(result.Pinned).toEqual([]); + expect(result.Recent).toEqual([]); + }); + + it('handles single message', () => { + const singleMessage: ConversationList = [ + { + conversation_id: '1', + last_message_timestamp: 1000, + topic_summary: 'Single Chat', + }, + ]; + + const result = getCategorizeMessages( + singleMessage, + [], + addProps, + undefined, + 'newest', + ); + + expect(result.Recent).toHaveLength(1); + expect(result.Recent[0].text).toBe('Single Chat'); + }); + + it('maintains sort order with same timestamps', () => { + const sameTimestampMessages: ConversationList = [ + { + conversation_id: '1', + last_message_timestamp: 1000, + topic_summary: 'First Chat', + }, + { + conversation_id: '2', + last_message_timestamp: 1000, + topic_summary: 'Second Chat', + }, + ]; + + const result = getCategorizeMessages( + sameTimestampMessages, + [], + addProps, + undefined, + 'alphabeticalAsc', + ); + + expect(result.Recent[0].text).toBe('First Chat'); + expect(result.Recent[1].text).toBe('Second Chat'); + }); + }); +}); + +describe('SortOption type', () => { + it('should accept valid sort options', () => { + const validOptions: SortOption[] = [ + 'newest', + 'oldest', + 'alphabeticalAsc', + 'alphabeticalDesc', + ]; + + validOptions.forEach(option => { + expect( + ['newest', 'oldest', 'alphabeticalAsc', 'alphabeticalDesc'].includes( + option, + ), + ).toBe(true); + }); + }); }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx b/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx index 9deae1d774..1c3d658faf 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx @@ -189,11 +189,41 @@ export const transformDocumentsToSources = ( }; }; +export type SortOption = + | 'newest' + | 'oldest' + | 'alphabeticalAsc' + | 'alphabeticalDesc'; + +const sortConversations = ( + messages: ConversationList, + sortOption: SortOption, +): ConversationList => { + return [...messages].sort((a, b) => { + switch (sortOption) { + case 'oldest': + return a.last_message_timestamp - b.last_message_timestamp; + case 'alphabeticalAsc': + return a.topic_summary.localeCompare(b.topic_summary, undefined, { + sensitivity: 'base', + }); + case 'alphabeticalDesc': + return b.topic_summary.localeCompare(a.topic_summary, undefined, { + sensitivity: 'base', + }); + case 'newest': + default: + return b.last_message_timestamp - a.last_message_timestamp; + } + }); +}; + export const getCategorizeMessages = ( messages: ConversationList, pinnedChats: string[], addProps: (c: ConversationSummary) => { [k: string]: any }, t?: (key: string, params?: any) => string, + sortOption: SortOption = 'newest', ): { [k: string]: Conversation[] } => { const pinnedChatsKey = t?.('conversation.category.pinnedChats') || 'Pinned'; const recentKey = t?.('conversation.category.recent') || 'Recent'; @@ -201,9 +231,7 @@ export const getCategorizeMessages = ( [pinnedChatsKey]: [], [recentKey]: [], }; - const sortedMessages = [...messages].sort( - (a, b) => b.last_message_timestamp - a.last_message_timestamp, - ); + const sortedMessages = sortConversations(messages, sortOption); sortedMessages.forEach(c => { const message: Conversation = { id: c.conversation_id, diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock index bba71afed5..a4a0c0f294 100644 --- a/workspaces/lightspeed/yarn.lock +++ b/workspaces/lightspeed/yarn.lock @@ -7442,27 +7442,25 @@ __metadata: languageName: node linkType: hard -"@monaco-editor/loader@npm:^1.4.0": - version: 1.4.0 - resolution: "@monaco-editor/loader@npm:1.4.0" +"@monaco-editor/loader@npm:^1.5.0": + version: 1.7.0 + resolution: "@monaco-editor/loader@npm:1.7.0" dependencies: state-local: ^1.0.6 - peerDependencies: - monaco-editor: ">= 0.21.0 < 1" - checksum: 374ec0ea872ee15b33310e105a43217148161480d3955c5cece87d0f801754cd2c45a3f6c539a75da18a066c1615756fb87eaf1003f1df6a64a0cbce5d2c3749 + checksum: e3a4f095827101216d72d0a0dcb4e4d29ae2c911a7ed59210b00e9ad48680a4132983a31170668918dd9b486c98fe97996357d58cc76fda34f3217848a19befb languageName: node linkType: hard -"@monaco-editor/react@npm:^4.6.0": - version: 4.6.0 - resolution: "@monaco-editor/react@npm:4.6.0" +"@monaco-editor/react@npm:^4.6.0, @monaco-editor/react@npm:^4.7.0": + version: 4.7.0 + resolution: "@monaco-editor/react@npm:4.7.0" dependencies: - "@monaco-editor/loader": ^1.4.0 + "@monaco-editor/loader": ^1.5.0 peerDependencies: monaco-editor: ">= 0.25.0 < 1" - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 9d44e76c5baad6db5f84c90a5540fbd3c9af691b97d76cf2a99b3c8273004d0efe44c2572d80e9d975c9af10022c21e4a66923924950a5201e82017c8b20428c + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 8b3bd8adfcd6af70dc5f965e986932269e1e2c2a0f6beb5a3c632c8c7942c1341f6086d9664f9a949983bdf4a04a706e529a93bfec3b5884642915dfcc0354c3 languageName: node linkType: hard @@ -8562,13 +8560,14 @@ __metadata: languageName: node linkType: hard -"@patternfly/chatbot@npm:6.4.1": - version: 6.4.1 - resolution: "@patternfly/chatbot@npm:6.4.1" +"@patternfly/chatbot@npm:6.5.0-prerelease.28": + version: 6.5.0-prerelease.28 + resolution: "@patternfly/chatbot@npm:6.5.0-prerelease.28" dependencies: "@patternfly/react-code-editor": ^6.1.0 "@patternfly/react-core": ^6.1.0 "@patternfly/react-icons": ^6.1.0 + "@patternfly/react-styles": ^6.1.0 "@patternfly/react-table": ^6.1.0 "@segment/analytics-next": ^1.76.0 clsx: ^2.1.0 @@ -8576,14 +8575,22 @@ __metadata: posthog-js: ^1.194.4 react-markdown: ^9.0.1 rehype-external-links: ^3.0.0 + rehype-highlight: ^7.0.0 rehype-sanitize: ^6.0.0 rehype-unwrap-images: ^1.0.0 remark-gfm: ^4.0.0 unist-util-visit: ^5.0.0 peerDependencies: + "@monaco-editor/react": ^4.7.0 + monaco-editor: ^0.54.0 react: ^18 || ^19 react-dom: ^18 || ^19 - checksum: ca0526885670b0c7d4dce2d204da4f323c59bdd1df449fecd3f15fbf4a0e189252e7e58c6e186dce7f1749004f520d7869846866d739724330dbc8c59ee8152e + peerDependenciesMeta: + "@monaco-editor/react": + optional: false + monaco-editor: + optional: false + checksum: 7e3c3ac13fc2a459ec93d45d448808debe3ac90879ffe889d417042fb880cf4151946dd12682441e9be1811f32c5de44705a275f711c2323c69c9fcf892391f9 languageName: node linkType: hard @@ -11063,9 +11070,10 @@ __metadata: "@ianvs/prettier-plugin-sort-imports": ^4.4.0 "@material-ui/core": ^4.9.13 "@material-ui/lab": ^4.0.0-alpha.61 + "@monaco-editor/react": ^4.7.0 "@mui/icons-material": ^6.1.8 "@mui/material": ^5.12.2 - "@patternfly/chatbot": 6.4.1 + "@patternfly/chatbot": 6.5.0-prerelease.28 "@patternfly/react-core": 6.4.0 "@patternfly/react-icons": ^6.3.1 "@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^" @@ -11075,6 +11083,7 @@ __metadata: "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^15.0.0 "@testing-library/user-event": 14.6.1 + monaco-editor: ^0.54.0 msw: 1.3.5 prettier: 3.6.2 react: 16.13.1 || ^17.0.0 || ^18.0.0 @@ -19182,6 +19191,13 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:3.1.7": + version: 3.1.7 + resolution: "dompurify@npm:3.1.7" + checksum: 0a9b811bbc94f3dba60cf6486962362b0f1a5b4ab789f5e1cbd4749b6ba1a1fad190a677a962dc8850ce28764424765fe425e9d6508e4e93ba648ef15d54bc24 + languageName: node + linkType: hard + "dompurify@npm:=3.2.6": version: 3.2.6 resolution: "dompurify@npm:3.2.6" @@ -22085,6 +22101,18 @@ __metadata: languageName: node linkType: hard +"hast-util-to-text@npm:^4.0.0": + version: 4.0.2 + resolution: "hast-util-to-text@npm:4.0.2" + dependencies: + "@types/hast": ^3.0.0 + "@types/unist": ^3.0.0 + hast-util-is-element: ^3.0.0 + unist-util-find-after: ^5.0.0 + checksum: 72cce08666b86511595d3eef52236b86897cfbac166f2a0752b70b16d1f590b5aa91ea1a553c0d1603f9e0c7e373ceacab381be3d8f176129ad6e301d2a56d94 + languageName: node + linkType: hard + "hast-util-whitespace@npm:^2.0.0": version: 2.0.1 resolution: "hast-util-whitespace@npm:2.0.1" @@ -22178,6 +22206,13 @@ __metadata: languageName: node linkType: hard +"highlight.js@npm:~11.11.0": + version: 11.11.1 + resolution: "highlight.js@npm:11.11.1" + checksum: 841ddd329a92be123a61ef4051698f824c3575deef588fbb810bd638f1575ddc96b7bdd925b97c05a29276bb119dfcb8e5bcb0acb31c86249371bf046e131d72 + languageName: node + linkType: hard + "highlightjs-vue@npm:^1.0.0": version: 1.0.0 resolution: "highlightjs-vue@npm:1.0.0" @@ -25586,6 +25621,17 @@ __metadata: languageName: node linkType: hard +"lowlight@npm:^3.0.0": + version: 3.3.0 + resolution: "lowlight@npm:3.3.0" + dependencies: + "@types/hast": ^3.0.0 + devlop: ^1.0.0 + highlight.js: ~11.11.0 + checksum: 0903db036d938d7bd4a7ce9b55f9e4cd02c46b932d632f512540d700fb0f61a689553231f6d15bc6ebf8a7372c58254f7efc3f18a7d4cb328ee366290a694801 + languageName: node + linkType: hard + "lru-cache@npm:@wolfy1339/lru-cache@^11.0.2-patch.1": version: 11.0.2-patch.1 resolution: "@wolfy1339/lru-cache@npm:11.0.2-patch.1" @@ -25804,6 +25850,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:14.0.0": + version: 14.0.0 + resolution: "marked@npm:14.0.0" + bin: + marked: bin/marked.js + checksum: 965405cde11d180e5da78cf51074b1947f1e5483ed99691d3ec990a078aa6ca9113cf19bbd44da45473d0e7eec7298255e7d860a650dd5e7aed78ca5a8e0161b + languageName: node + linkType: hard + "marked@npm:^4.0.14": version: 4.3.0 resolution: "marked@npm:4.3.0" @@ -27401,6 +27456,16 @@ __metadata: languageName: node linkType: hard +"monaco-editor@npm:^0.54.0": + version: 0.54.0 + resolution: "monaco-editor@npm:0.54.0" + dependencies: + dompurify: 3.1.7 + marked: 14.0.0 + checksum: 27bda0afafe5bbce693be9c72531e772bf71cfce6362a8358acd28815ddb95113cc1738ddccb28359a9d9def00da4dd928c1f32514dc0d1611ba0251758c3e2a + languageName: node + linkType: hard + "moo@npm:^0.5.0": version: 0.5.2 resolution: "moo@npm:0.5.2" @@ -31414,6 +31479,19 @@ __metadata: languageName: node linkType: hard +"rehype-highlight@npm:^7.0.0": + version: 7.0.2 + resolution: "rehype-highlight@npm:7.0.2" + dependencies: + "@types/hast": ^3.0.0 + hast-util-to-text: ^4.0.0 + lowlight: ^3.0.0 + unist-util-visit: ^5.0.0 + vfile: ^6.0.0 + checksum: 1382766418e3cd5fb21a4a1aced577cb6472058de958ceb1ebce763a17321786ef0658ecd34bdf096f0733973dbcb284653302b6ee7bd775442c0de07291000d + languageName: node + linkType: hard + "rehype-sanitize@npm:^6.0.0": version: 6.0.0 resolution: "rehype-sanitize@npm:6.0.0" @@ -34953,6 +35031,16 @@ __metadata: languageName: node linkType: hard +"unist-util-find-after@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-find-after@npm:5.0.0" + dependencies: + "@types/unist": ^3.0.0 + unist-util-is: ^6.0.0 + checksum: e64bd5ebee7ac021cf990bf33e9ec29fc6452159187d4a7fa0f77334bea8e378fea7a7fb0bcf957300b2ffdba902ff25b62c165fc8b86309613da35ad793ada0 + languageName: node + linkType: hard + "unist-util-generated@npm:^2.0.0": version: 2.0.1 resolution: "unist-util-generated@npm:2.0.1"