diff --git a/__tests__/components/KeywordsTable.test.tsx b/__tests__/components/KeywordsTable.test.tsx new file mode 100644 index 00000000..b8dbc94e --- /dev/null +++ b/__tests__/components/KeywordsTable.test.tsx @@ -0,0 +1,416 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import KeywordsTable from '../../components/keywords/KeywordsTable'; +import { KeywordType, SettingsType, DomainType } from '../../types'; + +// Mock child components and hooks to isolate KeywordsTable logic +const MockIcon = (props: any) => ; +MockIcon.displayName = 'MockIcon'; +jest.mock('../../components/common/Icon', () => MockIcon); + +const MockKeyword = (props: any) => ( +
+ + act(() => props.showKeywordDetails())} data-testid={`keyword-details-link-${props.keywordData.ID}`}>details +
+); +MockKeyword.displayName = 'MockKeyword'; +jest.mock('../../components/keywords/Keyword', () => MockKeyword); + +const MockKeywordDetails = () =>
Keyword Details
; +MockKeywordDetails.displayName = 'MockKeywordDetails'; +jest.mock('../../components/keywords/KeywordDetails', () => MockKeywordDetails); + +const MockKeywordFilter = () =>
Keyword Filters
; +MockKeywordFilter.displayName = 'MockKeywordFilter'; +jest.mock('../../components/keywords/KeywordFilter', () => MockKeywordFilter); + +const MockModal = ({ children }: { children: React.ReactNode}) =>
{children}
; +MockModal.displayName = 'MockModal'; +jest.mock('../../components/common/Modal', () => MockModal); + +jest.mock('../../services/keywords', () => ({ + useDeleteKeywords: () => ({ mutate: jest.fn() }), + useFavKeywords: () => ({ mutate: jest.fn() }), + useRefreshKeywords: () => ({ mutate: jest.fn() }), +})); +jest.mock('../../hooks/useWindowResize', () => jest.fn()); +jest.mock('../../hooks/useIsMobile', () => jest.fn(() => [false])); +jest.mock('../../services/settings', () => ({ + useUpdateSettings: () => ({ mutate: jest.fn(), isLoading: false }), +})); + +const MockToaster = () =>
; +MockToaster.displayName = 'MockToaster'; +jest.mock('react-hot-toast', () => ({ Toaster: MockToaster })); + +const MockFixedSizeList = ({ children, ...rest }: any) => ( +
+ {Array.from({ length: rest.itemCount }).map((_, index) => + children({ data: rest.itemData, index, style: {} }), + )} +
+); +MockFixedSizeList.displayName = 'MockFixedSizeList'; +jest.mock('react-window', () => ({ + FixedSizeList: MockFixedSizeList, +})); + +const mockKeywords: KeywordType[] = [ + { + ID: 1, + keyword: 'Keyword A', + device: 'desktop', + position: 1, + country: 'US', + tags: [], + history: {}, + lastUpdated: new Date().toISOString(), + domain: 'test.com', + volume: 100, + url: 'test.com/a', + }, + { + ID: 2, + keyword: 'Keyword B', + device: 'desktop', + position: 2, + country: 'US', + tags: [], + history: {}, + lastUpdated: new Date().toISOString(), + domain: 'test.com', + volume: 200, + url: 'test.com/b', + }, + { + ID: 3, + keyword: 'Keyword C', + device: 'desktop', + position: 3, + country: 'US', + tags: [], + history: {}, + lastUpdated: new Date().toISOString(), + domain: 'test.com', + volume: 300, + url: 'test.com/c', + }, + { + ID: 4, + keyword: 'Keyword D', + device: 'desktop', + position: 4, + country: 'US', + tags: [], + history: {}, + lastUpdated: new Date().toISOString(), + domain: 'test.com', + volume: 400, + url: 'test.com/d', + }, + { + ID: 5, + keyword: 'Keyword E', + device: 'desktop', + position: 5, + country: 'US', + tags: [], + history: {}, + lastUpdated: new Date().toISOString(), + domain: 'test.com', + volume: 500, + url: 'test.com/e', + }, +]; + +const mockDomain: DomainType = { + ID: 1, + domain: 'test.com', + tld: 'com', + tags: [], + added: new Date().toISOString(), + totalPages: 10, + CMS: 'WordPress', + server: 'Apache', + DA: 10, + DR: 10, + keywords: { desktop: 10, mobile: 5 }, + traffic: { desktop: 100, mobile: 50 }, + cost: { desktop: 100, mobile: 50 }, + user_id: 1, + favicon: '', + hasGSC: false, +}; + +const mockSettings: SettingsType = { + ID: 1, + user_id: 1, + createdAt: '', + updatedAt: '', + generalNotifications: true, + summaryEmails: true, + alertEmails: true, + autoRefresh: true, + defaultChartPeriod: '7', + theme: 'light', + timeZone: 'UTC', + dateFormat: 'MM/dd/yyyy', + keywordsColumns: ['Best', 'History', 'Volume', 'Search Console'], +}; + +describe('KeywordsTable Shift-Click Functionality', () => { + beforeEach(() => { + // Reset mocks if needed, e.g., jest.clearAllMocks(); + // Mock window.innerHeight for react-window + Object.defineProperty(window, 'innerHeight', { writable: true, configurable: true, value: 800 }); + }); + + test('1. Basic Shift-click selection (selects from first to third)', async () => { + render( + , + ); + + const keyword1Checkbox = screen.getByTestId('keyword-checkbox-1'); + const keyword3Checkbox = screen.getByTestId('keyword-checkbox-3'); + + // Simulate a click on the first keyword + fireEvent.click(keyword1Checkbox); + + // Simulate a Shift-click on the third keyword + fireEvent.click(keyword3Checkbox, { shiftKey: true }); + + // Assert that keywords at indices 0, 1, and 2 (IDs 1, 2, 3) are selected + // Need to wait for state updates if selection is async + // await screen.findByText('Keyword A'); // Example wait, adjust as needed + expect(screen.getByTestId('keyword-row-1')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-2')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-3')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-4')).not.toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-5')).not.toHaveClass('keyword--selected'); + }); + + test('2. Shift-click selection in reverse order (selects from third to first)', async () => { + render( + , + ); + + const keyword1Checkbox = screen.getByTestId('keyword-checkbox-1'); + const keyword3Checkbox = screen.getByTestId('keyword-checkbox-3'); + + // Simulate a click on the third keyword + fireEvent.click(keyword3Checkbox); + + // Simulate a Shift-click on the first keyword + fireEvent.click(keyword1Checkbox, { shiftKey: true }); + + expect(screen.getByTestId('keyword-row-1')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-2')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-3')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-4')).not.toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-5')).not.toHaveClass('keyword--selected'); + }); + + test('3. Shift-click with an existing disjointed selection', async () => { + render( + , + ); + + const keyword1Checkbox = screen.getByTestId('keyword-checkbox-1'); // A + const keyword3Checkbox = screen.getByTestId('keyword-checkbox-3'); // C + const keyword5Checkbox = screen.getByTestId('keyword-checkbox-5'); // B + + // Simulate a click on the first keyword (A) + fireEvent.click(keyword1Checkbox); + // Simulate a click on the fifth keyword (B) + fireEvent.click(keyword5Checkbox); + + // Now A (ID 1) and B (ID 5) are selected. + // Last selected was B (ID 5). + // Shift-click on the third keyword (C, ID 3). + // Range should be between B (index 4) and C (index 2). So, IDs 3, 4, 5. + // Keyword A (ID 1) should also remain selected. + fireEvent.click(keyword3Checkbox, { shiftKey: true }); + + expect(screen.getByTestId('keyword-row-1')).toHaveClass('keyword--selected'); // A + expect(screen.getByTestId('keyword-row-2')).not.toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-3')).toHaveClass('keyword--selected'); // C and in range B-C + expect(screen.getByTestId('keyword-row-4')).toHaveClass('keyword--selected'); // In range B-C + expect(screen.getByTestId('keyword-row-5')).toHaveClass('keyword--selected'); // B + }); + + test('4. Normal click after Shift-click (clears previous shift selection)', async () => { + render( + , + ); + + const keyword1Checkbox = screen.getByTestId('keyword-checkbox-1'); + const keyword3Checkbox = screen.getByTestId('keyword-checkbox-3'); + const keyword4Checkbox = screen.getByTestId('keyword-checkbox-4'); + + // Simulate a click on the first keyword + fireEvent.click(keyword1Checkbox); + // Simulate a Shift-click on the third keyword (selects 1, 2, 3) + fireEvent.click(keyword3Checkbox, { shiftKey: true }); + + // Simulate a normal click on keyword 4 + fireEvent.click(keyword4Checkbox); + + expect(screen.getByTestId('keyword-row-1')).not.toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-2')).not.toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-3')).not.toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-4')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-5')).not.toHaveClass('keyword--selected'); + }); + + test('5. Shift-click when no prior keyword was selected (acts as normal click)', () => { + render( + , + ); + + const keyword2Checkbox = screen.getByTestId('keyword-checkbox-2'); + fireEvent.click(keyword2Checkbox, { shiftKey: true }); + + expect(screen.getByTestId('keyword-row-1')).not.toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-2')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-3')).not.toHaveClass('keyword--selected'); + }); + + test('6. Shift-click on an already selected keyword within a potential new range', () => { + render( + , + ); + + const keyword1Checkbox = screen.getByTestId('keyword-checkbox-1'); + const keyword2Checkbox = screen.getByTestId('keyword-checkbox-2'); + const keyword3Checkbox = screen.getByTestId('keyword-checkbox-3'); + + // 1. Select keyword 1 + fireEvent.click(keyword1Checkbox); // Selected: [1] + // 2. Select keyword 2 (normally, without shift) + fireEvent.click(keyword2Checkbox); // Selected: [1, 2] (assuming non-shift adds to selection if not present) + // Based on original logic: if (selectedKeywords.includes(keywordID)) updatedSelectd = selectedKeywords.filter... + // This means a normal click on K2 would make selected: [1,2] if K2 was not selected. + // And if K2 *was* selected, it would be removed. Let's assume fresh clicks for now. + // The provided selectKeyword logic: + // else { // Original logic for single select/deselect + // let updatedSelected = [...selectedKeywords, keywordID]; + // if (selectedKeywords.includes(keywordID)) { // <-- This is the part + // updatedSelected = selectedKeywords.filter((keyID) => keyID !== keywordID); + // } + // setSelectedKeywords(updatedSelected); + // } + // So, clicking K1 makes [1]. Clicking K2 makes [1,2]. + + expect(screen.getByTestId('keyword-row-1')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-2')).toHaveClass('keyword--selected'); + + // 3. Shift-click keyword 3. Last selected was keyword 2. + // Expected: range from 2 to 3 (i.e., keywords 2, 3). Keyword 1 should remain selected. + // Result: [1, 2, 3] + fireEvent.click(keyword3Checkbox, { shiftKey: true }); + + expect(screen.getByTestId('keyword-row-1')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-2')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-3')).toHaveClass('keyword--selected'); + }); + + test('7. Shift-click that spans across already selected items (should select all in range)', () => { + render( + , + ); + + const keyword1Checkbox = screen.getByTestId('keyword-checkbox-1'); + const keyword3Checkbox = screen.getByTestId('keyword-checkbox-3'); + const keyword5Checkbox = screen.getByTestId('keyword-checkbox-5'); + + // 1. Select keyword 3 + fireEvent.click(keyword3Checkbox); // Selected: [3] + expect(screen.getByTestId('keyword-row-3')).toHaveClass('keyword--selected'); + + + // 2. Shift-click keyword 1. Last selected was keyword 3. + // Range is from 1 to 3. Expected: [1, 2, 3] + fireEvent.click(keyword1Checkbox, { shiftKey: true }); + expect(screen.getByTestId('keyword-row-1')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-2')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-3')).toHaveClass('keyword--selected'); + + // 3. Shift-click keyword 5. Last selected is now keyword 1 (from the previous shift-click action, which adds to selection). + // Actually, the last *clicked* keyword for non-shift selection determines the anchor for subsequent shift clicks. + // The problem description for shift click says: "If there was a previously selected keyword (i.e., `selectedKeywords` is not empty), find its index as well." + // And "const lastSelectedKeywordId = selectedKeywords[selectedKeywords.length - 1];" + // So after step 2, selectedKeywords is [3,1,2] (order due to Set conversion then spread). lastSelectedKeywordId = 2. + // Shift clicking K5 (ID 5). Range between ID 2 and ID 5. So IDs 2,3,4,5. + // Current selected: [3,1,2]. New to add: [2,3,4,5]. Resulting Set: [1,2,3,4,5] + fireEvent.click(keyword5Checkbox, { shiftKey: true }); + + expect(screen.getByTestId('keyword-row-1')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-2')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-3')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-4')).toHaveClass('keyword--selected'); + expect(screen.getByTestId('keyword-row-5')).toHaveClass('keyword--selected'); + }); +}); diff --git a/components/keywords/Keyword.tsx b/components/keywords/Keyword.tsx index 8550ec46..fe8be9a7 100644 --- a/components/keywords/Keyword.tsx +++ b/components/keywords/Keyword.tsx @@ -15,7 +15,7 @@ type KeywordProps = { refreshkeyword: Function, favoriteKeyword: Function, removeKeyword: Function, - selectKeyword: Function, + selectKeyword: (id: number, event: React.MouseEvent) => void, manageTags: Function, showKeywordDetails: Function, lastItem?:boolean, @@ -99,9 +99,10 @@ const Keyword = (props: KeywordProps) => {
diff --git a/components/keywords/KeywordsTable.tsx b/components/keywords/KeywordsTable.tsx index e160f97d..29aa84e4 100644 --- a/components/keywords/KeywordsTable.tsx +++ b/components/keywords/KeywordsTable.tsx @@ -83,13 +83,39 @@ const KeywordsTable = (props: KeywordsTableProps) => { return [...new Set(allTags)]; }, [keywords]); - const selectKeyword = (keywordID: number) => { - console.log('Select Keyword: ', keywordID); - let updatedSelectd = [...selectedKeywords, keywordID]; - if (selectedKeywords.includes(keywordID)) { - updatedSelectd = selectedKeywords.filter((keyID) => keyID !== keywordID); + const selectKeyword = (keywordID: number, event?: React.MouseEvent) => { + if (event?.shiftKey) { + const currentKeywordId = keywordID; + const currentKeywords = processedKeywords[device]; + const currentKeywordIndex = currentKeywords.findIndex(kw => kw.ID === currentKeywordId); + + if (currentKeywordIndex === -1) return; + + let rangeStart = currentKeywordIndex; + let rangeEnd = currentKeywordIndex; + + if (selectedKeywords.length > 0) { + const lastSelectedKeywordId = selectedKeywords[selectedKeywords.length - 1]; + const lastSelectedKeywordIndex = currentKeywords.findIndex(kw => kw.ID === lastSelectedKeywordId); + + if (lastSelectedKeywordIndex !== -1) { + rangeStart = Math.min(lastSelectedKeywordIndex, currentKeywordIndex); + rangeEnd = Math.max(lastSelectedKeywordIndex, currentKeywordIndex); + } + } + + const keywordsInRange = currentKeywords.slice(rangeStart, rangeEnd + 1); + const keywordIdsInRange = keywordsInRange.map(kw => kw.ID); + // Add new range to existing selections, avoid duplicates + setSelectedKeywords(prevSelected => [...new Set([...prevSelected, ...keywordIdsInRange])]); + } else { + // Original logic for single select/deselect + let updatedSelected = [...selectedKeywords, keywordID]; + if (selectedKeywords.includes(keywordID)) { + updatedSelected = selectedKeywords.filter((keyID) => keyID !== keywordID); + } + setSelectedKeywords(updatedSelected); } - setSelectedKeywords(updatedSelectd); }; const updateColumns = (column:string) => { @@ -109,7 +135,7 @@ const KeywordsTable = (props: KeywordsTableProps) => { style={style} index={index} selected={selectedKeywords.includes(keyword.ID)} - selectKeyword={selectKeyword} + selectKeyword={(id, event) => selectKeyword(id, event)} keywordData={keyword} refreshkeyword={() => refreshMutate({ ids: [keyword.ID] })} favoriteKeyword={favoriteMutate}