Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions __mocks__/@wordpress/api-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* WordPress dependencies
*/
import type { ApiFetch } from '@wordpress/api-fetch';

const apiFetch = jest.fn() as jest.Mock & ApiFetch;

apiFetch.use = jest.fn();
apiFetch.createNonceMiddleware = jest.fn( () => {
const middleware = jest.fn();
( middleware as jest.Mock & { nonce: string } ).nonce = '';
return middleware as jest.Mock & { nonce: string };
} );

export default apiFetch;
14 changes: 1 addition & 13 deletions assets/src/admin/onboarding/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,7 @@ import { createRoot } from 'react-dom/client';
/**
* Internal dependencies
*/
import OnboardingScreen, { type SiteType } from './page';

interface OneSearchOnboarding {
nonce: string;
site_type: SiteType | '';
setup_url: string;
}

declare global {
interface Window {
OneSearchOnboarding: OneSearchOnboarding;
}
}
import OnboardingScreen from './page';

// Render to the target element.
const target = document.getElementById( 'onesearch-site-selection-modal' );
Expand Down
24 changes: 16 additions & 8 deletions assets/src/admin/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ import {
SelectControl,
} from '@wordpress/components';

const BRAND_SITE = 'brand-site';
const GOVERNING_SITE = 'governing-site';
/**
* Internal dependencies
*/
import type { SiteType } from '../../types/global';

// Re-export for backward compatibility
export type { SiteType } from '../../types/global';

export type SiteType = typeof BRAND_SITE | typeof GOVERNING_SITE;
const BRAND_SITE = 'brand-site' as const;
const GOVERNING_SITE = 'governing-site' as const;

interface NoticeState {
type: 'success' | 'error' | 'warning' | 'info';
Expand All @@ -43,8 +49,8 @@ const SiteTypeSelector = ( {
value,
setSiteType,
}: {
value: SiteType | '';
setSiteType: ( v: SiteType | '' ) => void;
value: SiteType;
setSiteType: ( v: SiteType ) => void;
} ) => (
<SelectControl
label={ __( 'Site Type', 'onesearch' ) }
Expand All @@ -53,9 +59,11 @@ const SiteTypeSelector = ( {
"Choose your site's primary purpose. This setting cannot be changed later and affects available features and configurations.",
'onesearch'
) }
onChange={ ( v: SiteType | '' ) => {
onChange={ ( v: SiteType ) => {
setSiteType( v );
} }
__nextHasNoMarginBottom
__next40pxDefaultSize
options={ [
{ label: __( 'Select…', 'onesearch' ), value: '' },
{ label: __( 'Brand Site', 'onesearch' ), value: BRAND_SITE },
Expand All @@ -68,7 +76,7 @@ const SiteTypeSelector = ( {
);

const OnboardingScreen = () => {
const [ siteType, setSiteType ] = useState< SiteType | '' >(
const [ siteType, setSiteType ] = useState< SiteType >(
initialSiteType || ''
);
const [ notice, setNotice ] = useState< NoticeState | null >( null );
Expand All @@ -91,7 +99,7 @@ const OnboardingScreen = () => {
} );
}, [] ); // for initial component mount

const handleSiteTypeChange = async ( value: SiteType | '' ) => {
const handleSiteTypeChange = async ( value: SiteType ) => {
// Optimistically set site type.
setSiteType( value );
setIsSaving( true );
Expand Down
21 changes: 0 additions & 21 deletions assets/src/admin/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,6 @@ import { createRoot } from 'react-dom/client';
*/
import SettingsPage from './page';

export type SiteType = 'governing-site' | 'brand-site' | '';

interface OneSearchSettingsType {
restUrl: string;
restNonce: string;
api_key: string;
settingsLink: string;
siteType: SiteType;

// @todo legacy - to be removed later
restNamespace?: string;
nonce?: string;
currentSiteUrl?: string;
}

declare global {
interface Window {
OneSearchSettings: OneSearchSettingsType;
}
}

// Render to Gutenberg admin page with ID: onesearch-settings-page
const target = document.getElementById( 'onesearch-settings-page' );
if ( target ) {
Expand Down
2 changes: 1 addition & 1 deletion assets/src/admin/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const defaultBrandSite: BrandSite = {

export type EditingIndex = number | null;

const NONCE = window.OneSearchSettings.restNonce;
const NONCE = window.OneSearchSettings.nonce;
Comment thread
justlevine marked this conversation as resolved.
const SITE_TYPE = ( window.OneSearchSettings.siteType as SiteType ) || '';
const SHARED_SITES_ENDPOINT = '/onesearch/v1/shared-sites';

Expand Down
13 changes: 2 additions & 11 deletions assets/src/js/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,6 @@
*
* @return {boolean} True if the string is a valid URL, false otherwise.
*/
const isURL = ( str: string ): boolean => {
try {
new URL( str );
return true;
} catch {
return false;
}
};

/**
* Validates if a given string is a valid URL.
*
Expand All @@ -23,8 +14,8 @@ const isURL = ( str: string ): boolean => {
*/
export const isValidUrl = ( url: string ): boolean => {
try {
const parsedUrl = new URL( url );
return isURL( parsedUrl.href );
new URL( url );
return true;
} catch {
return false;
}
Expand Down
38 changes: 38 additions & 0 deletions assets/src/types/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Global type declarations for OneSearch.
*
* These types describe the window globals injected by WordPress PHP code.
*/

export type SiteType = 'governing-site' | 'brand-site' | '';

export interface OneSearchSharedSite {
name: string;
url: string;
api_key?: string;
}

export interface OneSearchSettings {
restUrl: string;
nonce: string;
api_key: string;
setupUrl: string;
siteType: SiteType;
sharedSites?: OneSearchSharedSite[];
restNamespace: string;
currentSiteUrl: string;
indexableEntities?: Record< string, string[] >;
}

export interface OneSearchOnboarding {
nonce: string;
site_type: SiteType | '';
setup_url: string;
}

declare global {
interface Window {
OneSearchSettings: OneSearchSettings;
OneSearchOnboarding: OneSearchOnboarding;
}
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@
"prepare": "lefthook install",
"start:js": "wp-scripts start --experimental-modules --blocks-manifest",
"test:e2e": "wp-scripts test-playwright",
"test:js": "wp-scripts test-unit-js --passWithNoTests",
"test:js": "wp-scripts test-unit-js",
"test:js:watch": "wp-scripts test-unit-js --watch",
"test:js:coverage": "wp-scripts test-unit-js --coverage --passWithNoTests",
"test:js:coverage": "wp-scripts test-unit-js --coverage",
"test:php": "wp-env run tests-cli --env-cwd=wp-content/plugins/onesearch vendor/bin/phpunit -c phpunit.xml.dist",
"wp-env": "wp-env",
"wp-env:cli": "wp-env run cli --env-cwd=wp-content/plugins/$(basename \"$(pwd)\")/"
Expand Down
177 changes: 177 additions & 0 deletions tests/js/AlgoliaSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* External dependencies
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';

/**
* WordPress dependencies
*/
import apiFetch from '@wordpress/api-fetch';

import AlgoliaSettings from '@/components/AlgoliaSettings';

const mockedApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >;

describe( 'AlgoliaSettings', () => {
afterEach( () => {
jest.clearAllMocks();
} );

it( 'loads saved credentials and keeps save disabled until values change', async () => {
const setNotice = jest.fn();

mockedApiFetch.mockImplementation( ( params ) => {
if (
params &&
typeof params === 'object' &&
( params as Record< string, unknown > )[ 'path' ] ===
'/onesearch/v1/algolia-credentials'
) {
return Promise.resolve( {
app_id: ' app-id ',
write_key: ' write-key ',
} );
}
return Promise.resolve( {} );
} );

render( <AlgoliaSettings setNotice={ setNotice } /> );

const appIdInput = await screen.findByRole( 'textbox', {
name: /Application ID/i,
} );
expect( appIdInput ).toHaveValue( 'app-id' );

expect( screen.getByLabelText( 'Write API Key*' ) ).toHaveValue(
'write-key'
);
expect(
screen.getByRole( 'button', { name: /Save Credentials/i } )
).toBeDisabled();
} );

it( 'shows an error notice when loading credentials fails', async () => {
const setNotice = jest.fn();

mockedApiFetch.mockImplementation( () =>
Promise.reject( new Error( 'failed' ) )
);

render( <AlgoliaSettings setNotice={ setNotice } /> );

await waitFor( () => {
expect( setNotice ).toHaveBeenCalledWith(
expect.objectContaining( {
type: 'error',
message: expect.stringMatching(
/Error fetching Algolia credentials/i
),
} )
);
} );
} );

it( 'saves changed credentials and reports success', async () => {
const setNotice = jest.fn();

mockedApiFetch.mockImplementation( ( params ) => {
if (
params &&
typeof params === 'object' &&
( params as Record< string, unknown > )[ 'method' ] === 'POST'
) {
return Promise.resolve( { success: true } );
}
if (
params &&
typeof params === 'object' &&
( params as Record< string, unknown > )[ 'path' ] ===
'/onesearch/v1/algolia-credentials'
) {
return Promise.resolve( {
app_id: 'app-id',
write_key: 'write-key',
} );
}
return Promise.resolve( {} );
} );

render( <AlgoliaSettings setNotice={ setNotice } /> );

const appIdInput = await screen.findByRole( 'textbox', {
name: /Application ID/i,
} );
fireEvent.change( appIdInput, { target: { value: 'updated-app' } } );
fireEvent.click(
screen.getByRole( 'button', { name: /Save Credentials/i } )
);

await waitFor( () => {
expect( mockedApiFetch ).toHaveBeenLastCalledWith(
expect.objectContaining( {
path: '/onesearch/v1/algolia-credentials',
method: 'POST',
data: expect.objectContaining( {
app_id: 'updated-app',
write_key: 'write-key',
} ),
} )
);
} );

expect( setNotice ).toHaveBeenCalledWith(
expect.objectContaining( {
type: 'success',
message: expect.stringMatching(
/Algolia credentials saved successfully/i
),
} )
);
} );

it( 'reports an error when saving fails', async () => {
const setNotice = jest.fn();

mockedApiFetch.mockImplementation( ( params ) => {
if (
params &&
typeof params === 'object' &&
( params as Record< string, unknown > )[ 'method' ] === 'POST'
) {
return Promise.reject( new Error( 'save failed' ) );
}
if (
params &&
typeof params === 'object' &&
( params as Record< string, unknown > )[ 'path' ] ===
'/onesearch/v1/algolia-credentials'
) {
return Promise.resolve( {
app_id: 'app-id',
write_key: 'write-key',
} );
}
return Promise.resolve( {} );
} );

render( <AlgoliaSettings setNotice={ setNotice } /> );

// Use getByLabelText for password field (not a textbox role)
const writeKeyInput = await screen.findByLabelText( /Write API Key/i );
fireEvent.change( writeKeyInput, { target: { value: 'new-key' } } );
fireEvent.click(
screen.getByRole( 'button', { name: /Save Credentials/i } )
);

await waitFor( () => {
expect( setNotice ).toHaveBeenCalledWith(
expect.objectContaining( {
type: 'error',
message: expect.stringMatching(
/Error saving Algolia credentials/i
),
} )
);
} );
} );
} );
Loading