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}