mirror of
https://github.com/towfiqi/serpbear
synced 2025-06-26 18:15:54 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f44911d7 | ||
|
|
9dce1d5b48 | ||
|
|
fbd23ede25 | ||
|
|
60c68bd339 | ||
|
|
2339e31af9 | ||
|
|
4a60271cac |
@@ -12,6 +12,7 @@
|
||||
"no-await-in-loop": "off",
|
||||
"arrow-body-style":"off",
|
||||
"max-len": ["error", {"code": 150, "ignoreComments": true, "ignoreUrls": true}],
|
||||
"import/no-extraneous-dependencies": "off",
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -2,6 +2,20 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
### [0.3.3](https://github.com/towfiqi/serpbear/compare/v0.3.2...v0.3.3) (2023-11-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Adds ability to visit pages from Insight tab ([60c68bd](https://github.com/towfiqi/serpbear/commit/60c68bd339db7aeed35aea035dd21691702ffee3))
|
||||
* Domains now show their favicon. ([2339e31](https://github.com/towfiqi/serpbear/commit/2339e31af9e90bf918f5bcd4f23114f38cef0313)), closes [#130](https://github.com/towfiqi/serpbear/issues/130)
|
||||
* Shows total keywords count in domains page ([fbd23ed](https://github.com/towfiqi/serpbear/commit/fbd23ede256062c72ec2f7e3983a0a02f0240725))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Resolves Website Thumbnail missing issue ([4a60271](https://github.com/towfiqi/serpbear/commit/4a60271cac1209dc02748c4d31943bb21c9ecaf2)), closes [#131](https://github.com/towfiqi/serpbear/issues/131)
|
||||
|
||||
### [0.3.2](https://github.com/towfiqi/serpbear/compare/v0.3.1...v0.3.2) (2023-11-09)
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ FROM node:lts-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app ./
|
||||
RUN rm -rf /app/data
|
||||
RUN rm -rf /app/__test__
|
||||
RUN rm -rf /app/__tests__
|
||||
RUN npm run build
|
||||
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ export const dummyDomain = {
|
||||
ID: 1,
|
||||
domain: 'compressimage.io',
|
||||
slug: 'compressimage-io',
|
||||
keywordCount: 0,
|
||||
keywordCount: 10,
|
||||
avgPosition: 24,
|
||||
lastUpdated: '2022-11-11T10:00:32.243',
|
||||
added: '2022-11-11T10:00:32.244',
|
||||
tags: [],
|
||||
tags: '',
|
||||
notification: true,
|
||||
notification_interval: 'daily',
|
||||
notification_emails: '',
|
||||
@@ -33,7 +34,7 @@ export const dummyKeywords = [
|
||||
lastResult: [],
|
||||
sticky: false,
|
||||
updating: false,
|
||||
lastUpdateError: 'false',
|
||||
lastUpdateError: false as false,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
@@ -56,6 +57,23 @@ export const dummyKeywords = [
|
||||
lastResult: [],
|
||||
sticky: false,
|
||||
updating: false,
|
||||
lastUpdateError: 'false',
|
||||
lastUpdateError: false as false,
|
||||
},
|
||||
];
|
||||
|
||||
export const dummySettings = {
|
||||
scaping_api: '',
|
||||
scraper_type: 'none',
|
||||
notification_interval: 'never',
|
||||
notification_email: '',
|
||||
notification_email_from: '',
|
||||
smtp_server: '',
|
||||
smtp_port: '',
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
scrape_retry: false,
|
||||
search_console_integrated: false,
|
||||
screenshot_key: '',
|
||||
available_scapers: [],
|
||||
failed_queue: [],
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import Modal from '../../components/common/Modal';
|
||||
|
||||
// jest.mock('React', () => ({
|
||||
// ...jest.requireActual('React'),
|
||||
// useEffect: jest.fn(),
|
||||
// }));
|
||||
// const mockedUseEffect = useEffect as jest.Mock<any>;
|
||||
|
||||
// jest.mock('../../components/common/Icon', () => () => <div data-testid="icon"/>);
|
||||
|
||||
describe('Modal Component', () => {
|
||||
it('Renders without crashing', async () => {
|
||||
render(<Modal closeModal={() => console.log() }><div></div></Modal>);
|
||||
// mockedUseEffect.mock.calls[0]();
|
||||
expect(document.querySelector('.modal')).toBeInTheDocument();
|
||||
});
|
||||
// it('Sets up the escapae key shortcut', async () => {
|
||||
// render(<Modal closeModal={() => console.log() }><div></div></Modal>);
|
||||
// expect(mockedUseEffect).toBeCalled();
|
||||
// });
|
||||
it('Displays the Given Content', async () => {
|
||||
render(<Modal closeModal={() => console.log() }>
|
||||
<div>
|
||||
<h1>Hello Modal!!</h1>
|
||||
</div>
|
||||
</Modal>);
|
||||
expect(await screen.findByText('Hello Modal!!')).toBeInTheDocument();
|
||||
});
|
||||
it('Renders Modal Title', async () => {
|
||||
render(<Modal closeModal={() => console.log() } title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
|
||||
expect(await screen.findByText('Sample Modal Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import Sidebar from '../../components/common/Sidebar';
|
||||
|
||||
describe('Sidebar Component', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(<Sidebar domains={[]} showAddModal={() => console.log() } />);
|
||||
expect(
|
||||
await screen.findByText('SerpBear'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { waitFor } from '@testing-library/react';
|
||||
// import { useFetchDomains } from '../../services/domains';
|
||||
// import { createWrapper } from '../utils';
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
query: { slug: 'compressimage-io' },
|
||||
push: (link:string) => { console.log('Pushed', link); },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('DomainHooks', () => {
|
||||
it('useFetchDomains should fetch the Domains', async () => {
|
||||
// const { result } = renderHook(() => useFetchDomains(), { wrapper: createWrapper() });
|
||||
const result = { current: { isSuccess: false, data: '' } };
|
||||
await waitFor(() => {
|
||||
console.log('result.current: ', result.current.data);
|
||||
return expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
__tests__/components/DomainItem.test.tsx
Normal file
35
__tests__/components/DomainItem.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import DomainItem from '../../components/domains/DomainItem';
|
||||
import { dummyDomain } from '../../__mocks__/data';
|
||||
|
||||
const updateThumbMock = jest.fn();
|
||||
const domainItemProps = {
|
||||
domain: dummyDomain,
|
||||
selected: false,
|
||||
isConsoleIntegrated: false,
|
||||
thumb: '',
|
||||
updateThumb: updateThumbMock,
|
||||
};
|
||||
|
||||
describe('DomainItem Component', () => {
|
||||
it('renders without crashing', async () => {
|
||||
const { container } = render(<DomainItem {...domainItemProps} />);
|
||||
expect(container.querySelector('.domItem')).toBeInTheDocument();
|
||||
});
|
||||
it('renders keywords count', async () => {
|
||||
const { container } = render(<DomainItem {...domainItemProps} />);
|
||||
const domStatskeywords = container.querySelector('.dom_stats div:nth-child(1)');
|
||||
expect(domStatskeywords?.textContent).toBe('Keywords10');
|
||||
});
|
||||
it('renders avg position', async () => {
|
||||
const { container } = render(<DomainItem {...domainItemProps} />);
|
||||
const domStatsAvg = container.querySelector('.dom_stats div:nth-child(2)');
|
||||
expect(domStatsAvg?.textContent).toBe('Avg position24');
|
||||
});
|
||||
it('updates domain thumbnail on relevant button click', async () => {
|
||||
const { container } = render(<DomainItem {...domainItemProps} />);
|
||||
const reloadThumbbBtn = container.querySelector('.domain_thumb button');
|
||||
if (reloadThumbbBtn) fireEvent.click(reloadThumbbBtn);
|
||||
expect(updateThumbMock).toHaveBeenCalledWith(dummyDomain.domain);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import Keyword from '../../components/keywords/Keyword';
|
||||
import { dummyKeywords } from '../data';
|
||||
import { dummyKeywords } from '../../__mocks__/data';
|
||||
|
||||
const keywordFunctions = {
|
||||
const keywordProps = {
|
||||
keywordData: dummyKeywords[0],
|
||||
selected: false,
|
||||
index: 0,
|
||||
showSCData: false,
|
||||
scDataType: '',
|
||||
style: {},
|
||||
refreshkeyword: jest.fn(),
|
||||
favoriteKeyword: jest.fn(),
|
||||
removeKeyword: jest.fn(),
|
||||
@@ -10,35 +16,37 @@ const keywordFunctions = {
|
||||
manageTags: jest.fn(),
|
||||
showKeywordDetails: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('react-chartjs-2', () => ({
|
||||
Line: () => null,
|
||||
}));
|
||||
describe('Keyword Component', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
render(<Keyword {...keywordProps} />);
|
||||
expect(await screen.findByText('compress image')).toBeInTheDocument();
|
||||
});
|
||||
it('Should Render Position Correctly', async () => {
|
||||
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
render(<Keyword {...keywordProps} />);
|
||||
const positionElement = document.querySelector('.keyword_position');
|
||||
expect(positionElement?.childNodes[0].nodeValue).toBe('19');
|
||||
});
|
||||
it('Should Display Position Change arrow', async () => {
|
||||
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
render(<Keyword {...keywordProps} />);
|
||||
const positionElement = document.querySelector('.keyword_position i');
|
||||
expect(positionElement?.textContent).toBe('▲ 1');
|
||||
});
|
||||
it('Should Display the SERP Page URL', async () => {
|
||||
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
render(<Keyword {...keywordProps} />);
|
||||
const positionElement = document.querySelector('.keyword_url');
|
||||
expect(positionElement?.textContent).toBe('/');
|
||||
});
|
||||
it('Should Display the Keyword Options on dots Click', async () => {
|
||||
render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
const button = document.querySelector('.keyword .keyword_dots');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
const { container } = render(<Keyword {...keywordProps} />);
|
||||
const button = container.querySelector('.keyword_dots');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(document.querySelector('.keyword_options')).toBeVisible();
|
||||
});
|
||||
// it('Should favorite Keywords', async () => {
|
||||
// render(<Keyword keywordData={dummyKeywords[0]} {...keywordFunctions} selected={false} />);
|
||||
// render(<Keyword {...keywordProps} />);
|
||||
// const button = document.querySelector('.keyword .keyword_dots');
|
||||
// if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
// const option = document.querySelector('.keyword .keyword_options li:nth-child(1) a');
|
||||
28
__tests__/components/Modal.test.tsx
Normal file
28
__tests__/components/Modal.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import Modal from '../../components/common/Modal';
|
||||
|
||||
const closeModalMock = jest.fn();
|
||||
describe('Modal Component', () => {
|
||||
it('Renders without crashing', async () => {
|
||||
render(<Modal closeModal={closeModalMock }><div></div></Modal>);
|
||||
expect(document.querySelector('.modal')).toBeInTheDocument();
|
||||
});
|
||||
it('Displays the Given Content', async () => {
|
||||
render(<Modal closeModal={closeModalMock}>
|
||||
<div>
|
||||
<h1>Hello Modal!!</h1>
|
||||
</div>
|
||||
</Modal>);
|
||||
expect(await screen.findByText('Hello Modal!!')).toBeInTheDocument();
|
||||
});
|
||||
it('Renders Modal Title', async () => {
|
||||
render(<Modal closeModal={closeModalMock} title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
|
||||
expect(await screen.findByText('Sample Modal Title')).toBeInTheDocument();
|
||||
});
|
||||
it('Closes the modal on close button click', async () => {
|
||||
const { container } = render(<Modal closeModal={closeModalMock} title="Sample Modal Title"><p>Some Modal Content</p></Modal>);
|
||||
const closeBtn = container.querySelector('.modal-close');
|
||||
if (closeBtn) fireEvent.click(closeBtn);
|
||||
expect(closeModalMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
23
__tests__/components/Sidebar.test.tsx
Normal file
23
__tests__/components/Sidebar.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import Sidebar from '../../components/common/Sidebar';
|
||||
import { dummyDomain } from '../../__mocks__/data';
|
||||
|
||||
const addDomainMock = jest.fn();
|
||||
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
|
||||
|
||||
describe('Sidebar Component', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
|
||||
expect(screen.getByText('SerpBear')).toBeInTheDocument();
|
||||
});
|
||||
it('renders domain list', async () => {
|
||||
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
|
||||
expect(screen.getByText('compressimage.io')).toBeInTheDocument();
|
||||
});
|
||||
it('calls showAddModal on Add Domain button click', async () => {
|
||||
render(<Sidebar domains={[dummyDomain]} showAddModal={addDomainMock} />);
|
||||
const addDomainBtn = screen.getByTestId('add_domain');
|
||||
fireEvent.click(addDomainBtn);
|
||||
expect(addDomainMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import TopBar from '../../components/common/TopBar';
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
pathname: '/',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('TopBar Component', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(<TopBar showSettings={() => console.log() } />);
|
||||
render(<TopBar showSettings={jest.fn} showAddModal={jest.fn} />);
|
||||
expect(
|
||||
await screen.findByText('SerpBear'),
|
||||
).toBeInTheDocument();
|
||||
27
__tests__/hooks/domains.test.tsx
Normal file
27
__tests__/hooks/domains.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import mockRouter from 'next-router-mock';
|
||||
|
||||
import { useFetchDomains } from '../../services/domains';
|
||||
import { createWrapper } from '../../__mocks__/utils';
|
||||
import { dummyDomain } from '../../__mocks__/data';
|
||||
|
||||
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
|
||||
|
||||
fetchMock.mockIf(`${window.location.origin}/api/domains`, async () => {
|
||||
return new Promise((resolve) => {
|
||||
resolve({
|
||||
body: JSON.stringify({ domains: [dummyDomain] }),
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DomainHooks', () => {
|
||||
it('useFetchDomains should fetch the Domains', async () => {
|
||||
const { result } = renderHook(() => useFetchDomains(mockRouter), { wrapper: createWrapper() });
|
||||
// const result = { current: { isSuccess: false, data: '' } };
|
||||
await waitFor(() => {
|
||||
return expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,26 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MockResponseInitFunction } from 'jest-fetch-mock';
|
||||
import SingleDomain from '../../pages/domain/[slug]';
|
||||
import { useAddDomain, useDeleteDomain, useFetchDomains, useUpdateDomain } from '../../services/domains';
|
||||
import { useAddKeywords, useDeleteKeywords, useFavKeywords, useFetchKeywords, useRefreshKeywords } from '../../services/keywords';
|
||||
import { dummyDomain, dummyKeywords } from '../data';
|
||||
import { dummyDomain, dummyKeywords, dummySettings } from '../../__mocks__/data';
|
||||
import { useFetchSettings } from '../../services/settings';
|
||||
|
||||
jest.mock('../../services/domains');
|
||||
jest.mock('../../services/keywords');
|
||||
jest.mock('../../services/settings');
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
query: { slug: dummyDomain.slug },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-chartjs-2', () => ({
|
||||
Line: () => null,
|
||||
}));
|
||||
|
||||
const useFetchDomainsFunc = useFetchDomains as jest.Mock<any>;
|
||||
const useFetchKeywordsFunc = useFetchKeywords as jest.Mock<any>;
|
||||
const useDeleteKeywordsFunc = useDeleteKeywords as jest.Mock<any>;
|
||||
@@ -21,9 +30,12 @@ const useAddDomainFunc = useAddDomain as jest.Mock<any>;
|
||||
const useAddKeywordsFunc = useAddKeywords as jest.Mock<any>;
|
||||
const useUpdateDomainFunc = useUpdateDomain as jest.Mock<any>;
|
||||
const useDeleteDomainFunc = useDeleteDomain as jest.Mock<any>;
|
||||
const useFetchSettingsFunc = useFetchSettings as jest.Mock<any>;
|
||||
|
||||
describe('SingleDomain Page', () => {
|
||||
const queryClient = new QueryClient();
|
||||
beforeEach(() => {
|
||||
useFetchSettingsFunc.mockImplementation(() => ({ data: { settings: dummySettings }, isLoading: false }));
|
||||
useFetchDomainsFunc.mockImplementation(() => ({ data: { domains: [dummyDomain] }, isLoading: false }));
|
||||
useFetchKeywordsFunc.mockImplementation(() => ({ keywordsData: { keywords: dummyKeywords }, keywordsLoading: false }));
|
||||
useDeleteKeywordsFunc.mockImplementation(() => ({ mutate: () => { } }));
|
||||
@@ -38,158 +50,163 @@ describe('SingleDomain Page', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('Render without crashing.', async () => {
|
||||
const { getByTestId } = render(<SingleDomain />);
|
||||
// screen.debug(undefined, Infinity);
|
||||
expect(getByTestId('domain-header')).toBeInTheDocument();
|
||||
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
expect(screen.getByTestId('domain-header')).toBeInTheDocument();
|
||||
});
|
||||
it('Should Call the useFetchDomains hook on render.', async () => {
|
||||
render(<SingleDomain />);
|
||||
// screen.debug(undefined, Infinity);
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
expect(useFetchDomains).toHaveBeenCalled();
|
||||
// expect(await result.findByText(/compressimage/i)).toBeInTheDocument();
|
||||
});
|
||||
it('Should Render the Keywords', async () => {
|
||||
render(<SingleDomain />);
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const keywordsCount = document.querySelectorAll('.keyword').length;
|
||||
expect(keywordsCount).toBe(2);
|
||||
});
|
||||
it('Should Display the Keywords Details Sidebar on Keyword Click.', async () => {
|
||||
render(<SingleDomain />);
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const keywords = document.querySelectorAll('.keyword');
|
||||
const firstKeyword = keywords && keywords[0].querySelector('a');
|
||||
if (firstKeyword) fireEvent(firstKeyword, new MouseEvent('click', { bubbles: true }));
|
||||
if (firstKeyword) fireEvent.click(firstKeyword);
|
||||
const fn: MockResponseInitFunction = async () => {
|
||||
return new Promise((resolve) => {
|
||||
resolve({
|
||||
body: JSON.stringify({ keyword: dummyKeywords[0] }),
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
};
|
||||
fetchMock.mockIf(`${window.location.origin}/api/keyword?id=${dummyKeywords[0].ID}`, fn);
|
||||
|
||||
expect(screen.getByTestId('keywordDetails')).toBeVisible();
|
||||
});
|
||||
it('Should Display the AddDomain Modal on Add Domain Button Click.', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=add_domain]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('add_domain');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(screen.getByTestId('adddomain_modal')).toBeVisible();
|
||||
});
|
||||
it('Should Display the AddKeywords Modal on Add Keyword Button Click.', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=add_keyword]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('add_keyword');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(screen.getByTestId('addkeywords_modal')).toBeVisible();
|
||||
});
|
||||
|
||||
it('Should display the Domain Settings on Settings Button click.', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=show_domain_settings]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('show_domain_settings');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(screen.getByTestId('domain_settings')).toBeVisible();
|
||||
});
|
||||
|
||||
it('Device Tab change should be functioning.', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=mobile_tab]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('mobile_tab');
|
||||
if (button) fireEvent.click(button);
|
||||
const keywordsCount = document.querySelectorAll('.keyword').length;
|
||||
expect(keywordsCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Search Filter should function properly', async () => {
|
||||
render(<SingleDomain />);
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const inputNode = screen.getByTestId('filter_input');
|
||||
fireEvent.change(inputNode, { target: { value: 'compressor' } }); // triggers onChange event
|
||||
if (inputNode) fireEvent.change(inputNode, { target: { value: 'compressor' } }); // triggers onChange event
|
||||
expect(inputNode.getAttribute('value')).toBe('compressor');
|
||||
const keywordsCount = document.querySelectorAll('.keyword').length;
|
||||
expect(keywordsCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Country Filter should function properly', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=filter_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('filter_button');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(document.querySelector('.country_filter')).toBeVisible();
|
||||
|
||||
const countrySelect = document.querySelector('.country_filter .selected');
|
||||
if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true }));
|
||||
if (countrySelect) fireEvent.click(countrySelect);
|
||||
expect(document.querySelector('.country_filter .select_list')).toBeVisible();
|
||||
const firstCountry = document.querySelector('.country_filter .select_list ul li:nth-child(1)');
|
||||
if (firstCountry) fireEvent(firstCountry, new MouseEvent('click', { bubbles: true }));
|
||||
if (firstCountry) fireEvent.click(firstCountry);
|
||||
const keywordsCount = document.querySelectorAll('.keyword').length;
|
||||
expect(keywordsCount).toBe(0);
|
||||
});
|
||||
|
||||
// Tags Filter should function properly
|
||||
it('Tags Filter should Render & Function properly', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=filter_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('filter_button');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(document.querySelector('.tags_filter')).toBeVisible();
|
||||
|
||||
const countrySelect = document.querySelector('.tags_filter .selected');
|
||||
if (countrySelect) fireEvent(countrySelect, new MouseEvent('click', { bubbles: true }));
|
||||
if (countrySelect) fireEvent.click(countrySelect);
|
||||
expect(document.querySelector('.tags_filter .select_list')).toBeVisible();
|
||||
expect(document.querySelectorAll('.tags_filter .select_list ul li').length).toBe(1);
|
||||
|
||||
const firstTag = document.querySelector('.tags_filter .select_list ul li:nth-child(1)');
|
||||
if (firstTag) fireEvent(firstTag, new MouseEvent('click', { bubbles: true }));
|
||||
if (firstTag) fireEvent.click(firstTag);
|
||||
expect(document.querySelectorAll('.keyword').length).toBe(1);
|
||||
});
|
||||
|
||||
it('Sort Options Should be visible Sort Button on Click.', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=sort_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('sort_button');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(document.querySelector('.sort_options')).toBeVisible();
|
||||
});
|
||||
|
||||
it('Sort: Position should sort keywords accordingly', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=sort_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('sort_button');
|
||||
if (button) fireEvent.click(button);
|
||||
// Test Top Position Sort
|
||||
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(1)');
|
||||
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (topPosSortOption) fireEvent.click(topPosSortOption);
|
||||
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(firstKeywordTitle).toBe('compress image');
|
||||
|
||||
// Test Lowest Position Sort
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
if (button) fireEvent.click(button);
|
||||
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(2)');
|
||||
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
|
||||
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(secondKeywordTitle).toBe('image compressor');
|
||||
});
|
||||
|
||||
it('Sort: Date Added should sort keywords accordingly', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=sort_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('sort_button');
|
||||
if (button) fireEvent.click(button);
|
||||
|
||||
// Test Top Position Sort
|
||||
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(3)');
|
||||
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (topPosSortOption) fireEvent.click(topPosSortOption);
|
||||
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(firstKeywordTitle).toBe('compress image');
|
||||
|
||||
// Test Lowest Position Sort
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
if (button) fireEvent.click(button);
|
||||
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(4)');
|
||||
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
|
||||
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(secondKeywordTitle).toBe('image compressor');
|
||||
});
|
||||
|
||||
it('Sort: Alphabetical should sort keywords accordingly', async () => {
|
||||
render(<SingleDomain />);
|
||||
const button = document.querySelector('[data-testid=sort_button]');
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
render(<QueryClientProvider client={queryClient}><SingleDomain /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('sort_button');
|
||||
if (button) fireEvent.click(button);
|
||||
|
||||
// Test Top Position Sort
|
||||
const topPosSortOption = document.querySelector('ul.sort_options li:nth-child(5)');
|
||||
if (topPosSortOption) fireEvent(topPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (topPosSortOption) fireEvent.click(topPosSortOption);
|
||||
const firstKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(firstKeywordTitle).toBe('compress image');
|
||||
|
||||
// Test Lowest Position Sort
|
||||
if (button) fireEvent(button, new MouseEvent('click', { bubbles: true }));
|
||||
if (button) fireEvent.click(button);
|
||||
const lowestPosSortOption = document.querySelector('ul.sort_options li:nth-child(6)');
|
||||
if (lowestPosSortOption) fireEvent(lowestPosSortOption, new MouseEvent('click', { bubbles: true }));
|
||||
if (lowestPosSortOption) fireEvent.click(lowestPosSortOption);
|
||||
const secondKeywordTitle = document.querySelector('.domKeywords_keywords .keyword:nth-child(1) a')?.textContent;
|
||||
expect(secondKeywordTitle).toBe('image compressor');
|
||||
});
|
||||
49
__tests__/pages/domains.test.tsx
Normal file
49
__tests__/pages/domains.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import * as ReactQuery from 'react-query';
|
||||
import { dummyDomain } from '../../__mocks__/data';
|
||||
import Domains from '../../pages/domains';
|
||||
|
||||
jest.mock('next/router', () => jest.requireActual('next-router-mock'));
|
||||
jest.spyOn(ReactQuery, 'useQuery').mockImplementation(jest.fn().mockReturnValue(
|
||||
{ data: { domains: [dummyDomain] }, isLoading: false, isSuccess: true },
|
||||
));
|
||||
|
||||
fetchMock.mockIf(`${window.location.origin}/api/domains`, async () => {
|
||||
return new Promise((resolve) => {
|
||||
resolve({
|
||||
body: JSON.stringify({ domains: [dummyDomain] }),
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Domains Page', () => {
|
||||
const queryClient = new QueryClient();
|
||||
it('Renders without crashing', async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Domains />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('domains')).toBeInTheDocument();
|
||||
});
|
||||
it('Renders the Domain Component', async () => {
|
||||
const { container } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Domains />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(container.querySelector('.domItem')).toBeInTheDocument();
|
||||
});
|
||||
it('Should Display Add Domain Modal on relveant Button Click.', async () => {
|
||||
render(<QueryClientProvider client={queryClient}><Domains /></QueryClientProvider>);
|
||||
const button = screen.getByTestId('addDomainButton');
|
||||
if (button) fireEvent.click(button);
|
||||
expect(screen.getByTestId('adddomain_modal')).toBeVisible();
|
||||
});
|
||||
it('Should Display the version number in Footer.', async () => {
|
||||
render(<QueryClientProvider client={queryClient}><Domains /></QueryClientProvider>);
|
||||
expect(screen.getByText('SerpBear v0.0.0')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -2,21 +2,16 @@ import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import Home from '../../pages/index';
|
||||
|
||||
const routerPush = jest.fn();
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: () => ({
|
||||
push: routerPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Home Page', () => {
|
||||
const queryClient = new QueryClient();
|
||||
it('Renders without crashing', async () => {
|
||||
// const dummyDomain = {
|
||||
// ID: 1,
|
||||
// domain: 'compressimage.io',
|
||||
// slug: 'compressimage-io',
|
||||
// keywordCount: 0,
|
||||
// lastUpdated: '2022-11-11T10:00:32.243',
|
||||
// added: '2022-11-11T10:00:32.244',
|
||||
// tags: [],
|
||||
// notification: true,
|
||||
// notification_interval: 'daily',
|
||||
// notification_emails: '',
|
||||
// };
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Home />
|
||||
@@ -26,12 +21,12 @@ describe('Home Page', () => {
|
||||
expect(await screen.findByRole('main')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Add Domain')).not.toBeInTheDocument();
|
||||
});
|
||||
it('Should Display the Add Domain Modal when there are no Domains.', async () => {
|
||||
it('Should redirect to /domains route.', async () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Home />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByText('Add Domain')).toBeInTheDocument();
|
||||
expect(routerPush).toHaveBeenCalledWith('/domains');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -26,11 +27,11 @@ const Sidebar = ({ domains, showAddModal } : SidebarProps) => {
|
||||
rounded-r-none ${((`/domain/${d.slug}` === router.asPath || `/domain/console/${d.slug}` === router.asPath
|
||||
|| `/domain/insight/${d.slug}` === router.asPath)
|
||||
? 'bg-white text-zinc-800 border border-r-0' : 'text-zinc-500')}`}>
|
||||
<i className={'text-center leading-4 mr-2 inline-block rounded-full w-5 h-5 bg-orange-200 not-italic'}>
|
||||
{d.domain.charAt(0)}
|
||||
</i>
|
||||
<img
|
||||
className={' inline-block mr-1'}
|
||||
src={`https://www.google.com/s2/favicons?domain=${d.domain}&sz=16`} alt={d.domain}
|
||||
/>
|
||||
{d.domain}
|
||||
{/* <span>0</span> */}
|
||||
</a>
|
||||
</Link>
|
||||
</li>)
|
||||
|
||||
@@ -11,9 +11,10 @@ type DomainItemProps = {
|
||||
selected: boolean,
|
||||
isConsoleIntegrated: boolean,
|
||||
thumb: string,
|
||||
updateThumb: Function,
|
||||
}
|
||||
|
||||
const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb }: DomainItemProps) => {
|
||||
const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb, updateThumb }: DomainItemProps) => {
|
||||
const { keywordsUpdated, slug, keywordCount = 0, avgPosition = 0, scVisits = 0, scImpressions = 0, scPosition = 0 } = domain;
|
||||
// const router = useRouter();
|
||||
return (
|
||||
@@ -21,8 +22,20 @@ const DomainItem = ({ domain, selected, isConsoleIntegrated = false, thumb }: Do
|
||||
<Link href={`/domain/${slug}`} passHref={true}>
|
||||
<a className='flex flex-col lg:flex-row'>
|
||||
<div className={`flex-1 p-6 flex ${!isConsoleIntegrated ? 'basis-1/3' : ''}`}>
|
||||
<div className="domain_thumb w-20 h-20 mr-6 bg-slate-100 rounded border border-gray-200 overflow-hidden">
|
||||
{thumb && <img src={thumb} alt={domain.domain} />}
|
||||
<div className="group domain_thumb w-20 h-20 mr-6 bg-slate-100 rounded
|
||||
border border-gray-200 overflow-hidden flex justify-center relative">
|
||||
<button
|
||||
className=' absolute right-1 top-0 text-gray-400 p-1 transition-all
|
||||
invisible opacity-0 group-hover:visible group-hover:opacity-100 hover:text-gray-600 z-10'
|
||||
title='Reload Website Screenshot'
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); updateThumb(domain.domain); }}
|
||||
>
|
||||
<Icon type="reload" size={12} />
|
||||
</button>
|
||||
<img
|
||||
className={`self-center ${!thumb ? 'max-w-[50px]' : ''}`}
|
||||
src={thumb || `https://www.google.com/s2/favicons?domain=${domain.domain}&sz=128`} alt={domain.domain}
|
||||
/>
|
||||
</div>
|
||||
<div className="domain_details flex-1">
|
||||
<h3 className='font-semibold text-base mb-2'>{domain.domain}</h3>
|
||||
|
||||
@@ -12,7 +12,7 @@ type SCInsightProps = {
|
||||
isConsoleIntegrated: boolean,
|
||||
}
|
||||
|
||||
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SCInsightProps) => {
|
||||
const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true, domain }: SCInsightProps) => {
|
||||
const [activeTab, setActiveTab] = useState<string>('stats');
|
||||
|
||||
const insightItems = insight[activeTab as keyof InsightDataType];
|
||||
@@ -108,7 +108,7 @@ const SCInsight = ({ insight, isLoading = true, isConsoleIntegrated = true }: SC
|
||||
(item:SCInsightItem, index: number) => {
|
||||
const insightItemCount = insight ? insightItems : [];
|
||||
const lastItem = !!(insightItemCount && (index === insightItemCount.length));
|
||||
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} />;
|
||||
return <InsightItem key={index} item={item} type={activeTab} lastItem={lastItem} domain={domain?.domain || ''} />;
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import Icon from '../common/Icon';
|
||||
type InsightItemProps = {
|
||||
item: SCInsightItem,
|
||||
lastItem: boolean,
|
||||
type: string
|
||||
type: string,
|
||||
domain: string
|
||||
}
|
||||
|
||||
const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
|
||||
const InsightItem = ({ item, lastItem, type, domain }:InsightItemProps) => {
|
||||
const { clicks, impressions, ctr, position, country = 'zzz', keyword, page, keywords = 0, countries: cntrs = 0, date } = item;
|
||||
let firstItem = keyword;
|
||||
if (type === 'pages') { firstItem = page; } if (type === 'stats') {
|
||||
@@ -24,7 +25,7 @@ const InsightItem = ({ item, lastItem, type }:InsightItemProps) => {
|
||||
|
||||
<div className=' w-3/4 lg:flex-1 lg:basis-20 lg:w-auto font-semibold'>
|
||||
{type === 'countries' && <span className={`fflag fflag-${country} w-[18px] h-[12px] mr-2`} />}
|
||||
{firstItem}
|
||||
{type === 'pages' && domain ? <a href={`https://${domain}${page}`} target='_blank' rel="noreferrer">{firstItem}</a> : firstItem}
|
||||
</div>
|
||||
|
||||
<div className='keyword_pos text-center inline-block mr-3 lg:mr-0 lg:flex-1'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import useUpdateSettings, { useFetchSettings } from '../../services/settings';
|
||||
import { useFetchSettings, useUpdateSettings } from '../../services/settings';
|
||||
import Icon from '../common/Icon';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import ScraperSettings from './ScraperSettings';
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import 'isomorphic-fetch';
|
||||
import './styles/globals.css';
|
||||
import '@testing-library/jest-dom';
|
||||
import { enableFetchMocks } from 'jest-fetch-mock';
|
||||
// Optional: configure or set up a testing framework before each test.
|
||||
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
|
||||
|
||||
// Used for __tests__/testing-library.js
|
||||
// Learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
window.matchMedia = (query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
});
|
||||
|
||||
global.ResizeObserver = require('resize-observer-polyfill');
|
||||
|
||||
// Enable Fetch Mocking
|
||||
enableFetchMocks();
|
||||
|
||||
41
package-lock.json
generated
41
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "serpbear",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "serpbear",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"dependencies": {
|
||||
"@googleapis/searchconsole": "^1.0.0",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
@@ -59,6 +59,8 @@
|
||||
"eslint-config-next": "12.3.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"next-router-mock": "^0.9.10",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.7.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
@@ -4152,6 +4154,15 @@
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz",
|
||||
"integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -7829,6 +7840,16 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-fetch-mock": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz",
|
||||
"integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cross-fetch": "^3.0.4",
|
||||
"promise-polyfill": "^8.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-get-type": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
|
||||
@@ -9840,6 +9861,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-router-mock": {
|
||||
"version": "0.9.10",
|
||||
"resolved": "https://registry.npmjs.org/next-router-mock/-/next-router-mock-0.9.10.tgz",
|
||||
"integrity": "sha512-bK6sRb/xGNFgHVUZuvuApn6KJBAKTPiP36A7a9mO77U4xQO5ukJx9WHlU67Tv8AuySd09pk0+Hu8qMVIAmLO6A==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"next": ">=10.0.0",
|
||||
"react": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
|
||||
@@ -10835,6 +10866,12 @@
|
||||
"integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/promise-polyfill": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
|
||||
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/promise-retry": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "serpbear",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -67,6 +67,8 @@
|
||||
"eslint-config-next": "12.3.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"next-router-mock": "^0.9.10",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.7.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
|
||||
@@ -55,6 +55,7 @@ const updateSettings = async (req: NextApiRequest, res: NextApiResponse<Settings
|
||||
};
|
||||
|
||||
export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
const screenshotAPIKey = process.env.SCREENSHOT_API || '69408-serpbear';
|
||||
try {
|
||||
const settingsRaw = await readFile(`${process.cwd()}/data/settings.json`, { encoding: 'utf-8' });
|
||||
const failedQueueRaw = await readFile(`${process.cwd()}/data/failed_queue.json`, { encoding: 'utf-8' });
|
||||
@@ -73,6 +74,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
search_console_integrated: !!(process.env.SEARCH_CONSOLE_PRIVATE_KEY && process.env.SEARCH_CONSOLE_CLIENT_EMAIL),
|
||||
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
|
||||
failed_queue: failedQueue,
|
||||
screenshot_key: screenshotAPIKey,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('Error Decrypting Settings API Keys!');
|
||||
@@ -81,7 +83,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
return decryptedSettings;
|
||||
} catch (error) {
|
||||
console.log('[ERROR] Getting App Settings. ', error);
|
||||
const settings = {
|
||||
const settings: SettingsType = {
|
||||
scraper_type: 'none',
|
||||
notification_interval: 'never',
|
||||
notification_email: '',
|
||||
@@ -91,6 +93,7 @@ export const getAppSettings = async () : Promise<SettingsType> => {
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
scrape_retry: false,
|
||||
screenshot_key: screenshotAPIKey,
|
||||
};
|
||||
const otherSettings = {
|
||||
available_scapers: allScrapers.map((scraper) => ({ label: scraper.name, value: scraper.id })),
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import TopBar from '../../components/common/TopBar';
|
||||
import AddDomain from '../../components/domains/AddDomain';
|
||||
import Settings from '../../components/settings/Settings';
|
||||
import { useFetchSettings } from '../../services/settings';
|
||||
import { useFetchDomains } from '../../services/domains';
|
||||
import { fetchDomainScreenshot, useFetchDomains } from '../../services/domains';
|
||||
import DomainItem from '../../components/domains/DomainItem';
|
||||
import Icon from '../../components/common/Icon';
|
||||
|
||||
type thumbImages = { [domain:string] : string }
|
||||
|
||||
const SingleDomain: NextPage = () => {
|
||||
const Domains: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const [noScrapprtError, setNoScrapprtError] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
@@ -22,33 +23,28 @@ const SingleDomain: NextPage = () => {
|
||||
const { data: appSettings } = useFetchSettings();
|
||||
const { data: domainsData, isLoading } = useFetchDomains(router, true);
|
||||
|
||||
const totalKeywords = useMemo(() => {
|
||||
let keywords = 0;
|
||||
if (domainsData?.domains) {
|
||||
domainsData.domains.forEach(async (domain:DomainType) => {
|
||||
keywords += domain?.keywordCount || 0;
|
||||
});
|
||||
}
|
||||
return keywords;
|
||||
}, [domainsData]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log('Domains Data: ', domainsData);
|
||||
if (domainsData?.domains && domainsData.domains.length > 0) {
|
||||
const domainThumbsRaw = localStorage.getItem('domainThumbs');
|
||||
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
|
||||
if (domainsData?.domains && domainsData.domains.length > 0 && appSettings?.settings?.screenshot_key) {
|
||||
domainsData.domains.forEach(async (domain:DomainType) => {
|
||||
if (domain.domain) {
|
||||
if (!domThumbs[domain.domain]) {
|
||||
const domainImageBlob = await fetch(`https://image.thum.io/get/auth/66909-serpbear/maxAge/96/width/200/https://${domain.domain}`).then((res) => res.blob());
|
||||
if (domainImageBlob) {
|
||||
const reader = new FileReader();
|
||||
await new Promise((resolve, reject) => {
|
||||
reader.onload = resolve;
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(domainImageBlob);
|
||||
});
|
||||
const imageBase: string = reader.result && typeof reader.result === 'string' ? reader.result : '';
|
||||
localStorage.setItem('domainThumbs', JSON.stringify({ ...domThumbs, [domain.domain]: imageBase }));
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: imageBase }));
|
||||
}
|
||||
} else {
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domThumbs[domain.domain] }));
|
||||
const domainThumb = await fetchDomainScreenshot(domain.domain, appSettings.settings.screenshot_key);
|
||||
if (domainThumb) {
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain.domain]: domainThumb }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [domainsData]);
|
||||
}, [domainsData, appSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log('appSettings.settings: ', appSettings && appSettings.settings);
|
||||
@@ -57,8 +53,20 @@ const SingleDomain: NextPage = () => {
|
||||
}
|
||||
}, [appSettings]);
|
||||
|
||||
const manuallyUpdateThumb = async (domain: string) => {
|
||||
if (domain && appSettings?.settings?.screenshot_key) {
|
||||
const domainThumb = await fetchDomainScreenshot(domain, appSettings.settings.screenshot_key, true);
|
||||
if (domainThumb) {
|
||||
toast(`${domain} Screenshot Updated Successfully!`, { icon: '✔️' });
|
||||
setDomainThumbs((currentThumbs) => ({ ...currentThumbs, [domain]: domainThumb }));
|
||||
} else {
|
||||
toast(`Failed to Fetch ${domain} Screenshot!`, { icon: '⚠️' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="Domain flex flex-col min-h-screen">
|
||||
<div data-testid="domains" className="Domain flex flex-col min-h-screen">
|
||||
{noScrapprtError && (
|
||||
<div className=' p-3 bg-red-600 text-white text-sm text-center'>
|
||||
A Scrapper/Proxy has not been set up Yet. Open Settings to set it up and start using the app.
|
||||
@@ -71,9 +79,12 @@ const SingleDomain: NextPage = () => {
|
||||
|
||||
<div className="flex flex-col w-full max-w-5xl mx-auto p-6 lg:mt-24 lg:p-0">
|
||||
<div className='flex justify-between mb-2 items-center'>
|
||||
<div className=' text-sm'>{domainsData?.domains?.length || 0} Domains</div>
|
||||
<div className=' text-sm text-gray-600'>
|
||||
{domainsData?.domains?.length || 0} Domains <span className=' text-gray-300 ml-1 mr-1'>|</span> {totalKeywords} keywords
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
data-testid="addDomainButton"
|
||||
className={'ml-2 inline-block py-2 text-blue-700 font-bold text-sm'}
|
||||
onClick={() => setShowAddDomain(true)}>
|
||||
<span
|
||||
@@ -90,6 +101,7 @@ const SingleDomain: NextPage = () => {
|
||||
selected={false}
|
||||
isConsoleIntegrated={!!(appSettings && appSettings?.settings?.search_console_integrated) }
|
||||
thumb={domainThumbs[domain.domain]}
|
||||
updateThumb={manuallyUpdateThumb}
|
||||
// isConsoleIntegrated={false}
|
||||
/>;
|
||||
})}
|
||||
@@ -115,8 +127,9 @@ const SingleDomain: NextPage = () => {
|
||||
<footer className='text-center flex flex-1 justify-center pb-5 items-end'>
|
||||
<span className='text-gray-500 text-xs'><a href='https://github.com/towfiqi/serpbear' target="_blank" rel='noreferrer'>SerpBear v{appSettings?.settings?.version || '0.0.0'}</a></span>
|
||||
</footer>
|
||||
<Toaster position='bottom-center' containerClassName="react_toaster" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleDomain;
|
||||
export default Domains;
|
||||
|
||||
@@ -8,7 +8,7 @@ import Icon from '../components/common/Icon';
|
||||
const Home: NextPage = () => {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.push('/domains');
|
||||
if (router) router.push('/domains');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,36 @@ export async function fetchDomains(router: NextRouter, withStats:boolean) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchDomainScreenshot(domain: string, screenshot_key:string, forceFetch = false): Promise<string | false> {
|
||||
const domainThumbsRaw = localStorage.getItem('domainThumbs');
|
||||
const domThumbs = domainThumbsRaw ? JSON.parse(domainThumbsRaw) : {};
|
||||
if (!domThumbs[domain] || forceFetch) {
|
||||
try {
|
||||
const screenshotURL = `https://image.thum.io/get/auth/${screenshot_key}/maxAge/96/width/200/https://${domain}`;
|
||||
const domainImageRes = await fetch(screenshotURL);
|
||||
const domainImageBlob = domainImageRes.status === 200 ? await domainImageRes.blob() : false;
|
||||
if (domainImageBlob) {
|
||||
const reader = new FileReader();
|
||||
await new Promise((resolve, reject) => {
|
||||
reader.onload = resolve;
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(domainImageBlob);
|
||||
});
|
||||
const imageBase: string = reader.result && typeof reader.result === 'string' ? reader.result : '';
|
||||
localStorage.setItem('domainThumbs', JSON.stringify({ ...domThumbs, [domain]: imageBase }));
|
||||
return imageBase;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
} else if (domThumbs[domain]) {
|
||||
return domThumbs[domain];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useFetchDomains(router: NextRouter, withStats:boolean = false) {
|
||||
return useQuery('domains', () => fetchDomains(router, withStats));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useFetchSettings() {
|
||||
return useQuery('settings', () => fetchSettings());
|
||||
}
|
||||
|
||||
const useUpdateSettings = (onSuccess:Function|undefined) => {
|
||||
export const useUpdateSettings = (onSuccess:Function|undefined) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(async (settings: SettingsType) => {
|
||||
@@ -60,5 +60,3 @@ export function useClearFailedQueue(onSuccess:Function) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default useUpdateSettings;
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"types.d.ts"
|
||||
"types.d.ts",
|
||||
"./jest.setup.js"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
3
types.d.ts
vendored
3
types.d.ts
vendored
@@ -83,7 +83,8 @@ type SettingsType = {
|
||||
scrape_delay?: string,
|
||||
scrape_retry?: boolean,
|
||||
failed_queue?: string[]
|
||||
version?: string
|
||||
version?: string,
|
||||
screenshot_key?: string,
|
||||
}
|
||||
|
||||
type KeywordSCDataChild = {
|
||||
|
||||
Reference in New Issue
Block a user