Skip to content

Commit 43583f0

Browse files
committed
No longer requiring Gutenberg
Rebased from trunk
1 parent a4a56e2 commit 43583f0

6 files changed

Lines changed: 196 additions & 99 deletions

File tree

projects/packages/search/src/class-ai-answers.php

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,57 +12,76 @@
1212
* jetpack_search_ai_answers_enabled option.
1313
*/
1414
class AI_Answers {
15-
const BEHAVIOR_META_KEY = '_guideline_block_jetpack_search-ai-summary';
15+
const BEHAVIOR_META_KEY = '_guideline_block_jetpack_search-ai-summary';
16+
const BEHAVIOR_OPTION_KEY = 'jetpack_search_ai_behavior_instructions';
1617

1718
/**
18-
* Hook up meta registration.
19+
* Hook up meta/setting registration.
1920
*/
2021
public function init() {
2122
add_action( 'rest_api_init', array( $this, 'register_behavior_meta' ) );
2223
}
2324

2425
/**
25-
* Register the block-specific meta key on the Gutenberg Guidelines CPT so it is
26-
* included in REST responses.
26+
* Register the behavior instructions storage for the REST API.
27+
*
28+
* When the Gutenberg Guidelines CPT is present, registers the block-specific
29+
* meta key on it. Otherwise registers a site option exposed via /wp/v2/settings.
2730
*/
2831
public function register_behavior_meta() {
29-
if ( ! post_type_exists( 'wp_guideline' ) ) {
32+
if ( post_type_exists( 'wp_guideline' ) ) {
33+
register_post_meta(
34+
'wp_guideline',
35+
self::BEHAVIOR_META_KEY,
36+
array(
37+
'single' => true,
38+
'type' => 'string',
39+
'show_in_rest' => true,
40+
'default' => '',
41+
'sanitize_callback' => 'sanitize_textarea_field',
42+
'auth_callback' => function () {
43+
return current_user_can( 'manage_options' );
44+
},
45+
)
46+
);
3047
return;
3148
}
32-
register_post_meta(
33-
'wp_guideline',
34-
self::BEHAVIOR_META_KEY,
49+
50+
register_setting(
51+
'options',
52+
self::BEHAVIOR_OPTION_KEY,
3553
array(
36-
'single' => true,
3754
'type' => 'string',
38-
'show_in_rest' => true,
3955
'default' => '',
4056
'sanitize_callback' => 'sanitize_textarea_field',
41-
'auth_callback' => function () {
42-
return current_user_can( 'manage_options' );
43-
},
57+
'show_in_rest' => true,
4458
)
4559
);
4660
}
4761

4862
/**
49-
* Retrieve the behavior instructions from the Gutenberg Guidelines singleton.
63+
* Retrieve the behavior instructions.
64+
*
65+
* Reads from the Gutenberg Guidelines CPT when available, otherwise falls
66+
* back to the site option.
5067
*
5168
* @return string Behavior instructions, or empty string if none saved.
5269
*/
5370
public static function get_behavior_instructions() {
54-
$posts = get_posts(
55-
array(
56-
'post_type' => 'wp_guideline',
57-
'posts_per_page' => 1,
58-
'post_status' => 'publish',
59-
)
60-
);
61-
if ( empty( $posts ) ) {
62-
return '';
71+
if ( post_type_exists( 'wp_guideline' ) ) {
72+
$posts = get_posts(
73+
array(
74+
'post_type' => 'wp_guideline',
75+
'posts_per_page' => 1,
76+
'post_status' => 'publish',
77+
)
78+
);
79+
if ( ! empty( $posts ) ) {
80+
$guidelines = get_post_meta( $posts[0]->ID, self::BEHAVIOR_META_KEY, true );
81+
return is_string( $guidelines ) ? $guidelines : '';
82+
}
6383
}
64-
$guidelines = get_post_meta( $posts[0]->ID, self::BEHAVIOR_META_KEY, true );
65-
return is_string( $guidelines ) ? $guidelines : '';
84+
return (string) get_option( self::BEHAVIOR_OPTION_KEY, '' );
6685
}
6786

6887
/**

projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getSiteFragment } from '@automattic/jetpack-shared-extension-utils';
33
import { TextareaControl, ToggleControl } from '@wordpress/components';
44
import { useSelect } from '@wordpress/data';
55
import { __ } from '@wordpress/i18n';
6-
import { Button, Link, Notice } from '@wordpress/ui';
6+
import { Button, Notice } from '@wordpress/ui';
77
import useAiAnswersSettings, { DEFAULT_PERSONALITY } from 'hooks/use-ai-answers-settings';
88
import useProductCheckoutWorkflow from 'hooks/use-product-checkout-workflow';
99
import useSearchSettings from 'hooks/use-search-settings';
@@ -139,7 +139,7 @@ export default function AiAnswersTab() {
139139
</>
140140
) }
141141

142-
{ ! isLoading && isUnavailable && isWpcomPlatformSite() && (
142+
{ ! isLoading && isUnavailable && (
143143
<Notice.Root intent="warning">
144144
<Notice.Title>
145145
{ __(
@@ -152,38 +152,6 @@ export default function AiAnswersTab() {
152152
</Notice.Description>
153153
</Notice.Root>
154154
) }
155-
{ ! isLoading && isUnavailable && ! isWpcomPlatformSite() && (
156-
<Notice.Root intent="warning">
157-
<Notice.Title>
158-
{ __(
159-
'Personality instructions require the Gutenberg Guidelines feature. To enable it:',
160-
'jetpack-search-pkg'
161-
) }
162-
</Notice.Title>
163-
<Notice.Description>
164-
<ol>
165-
<li>
166-
{ __(
167-
'Install or update to Gutenberg 22.7 or later.',
168-
'jetpack-search-pkg'
169-
) }
170-
</li>
171-
<li>
172-
{ __(
173-
'Go to Settings → Gutenberg → Experiments and enable "Guidelines".',
174-
'jetpack-search-pkg'
175-
) }{ ' ' }
176-
<Link
177-
href={ `${ siteAdminUrl }admin.php?page=gutenberg-experiments` }
178-
openInNewTab
179-
>
180-
{ __( 'Open Experiments page', 'jetpack-search-pkg' ) }
181-
</Link>
182-
</li>
183-
</ol>
184-
</Notice.Description>
185-
</Notice.Root>
186-
) }
187155
</div>
188156
</div>
189157
</div>

projects/packages/search/src/dashboard/hooks/test/use-ai-answers-settings.test.js

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import useAiAnswersSettings, { DEFAULT_PERSONALITY } from '../use-ai-answers-set
44

55
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
66

7+
const SETTINGS_KEY = 'jetpack_search_ai_behavior_instructions';
8+
79
const makePost = ( overrides = {} ) => ( {
810
id: 42,
911
guideline_categories: {
@@ -19,7 +21,7 @@ describe( 'useAiAnswersSettings', () => {
1921
jest.resetAllMocks();
2022
} );
2123

22-
describe( 'initial load', () => {
24+
describe( 'initial load — guidelines endpoint available', () => {
2325
it( 'starts in loading state', () => {
2426
apiFetch.mockReturnValue( new Promise( () => {} ) );
2527
const { result } = renderHook( () => useAiAnswersSettings() );
@@ -62,31 +64,55 @@ describe( 'useAiAnswersSettings', () => {
6264
expect( result.current.postId ).toBeNull();
6365
} );
6466

65-
it( 'sets isUnavailable on rest_no_route error', async () => {
66-
apiFetch.mockRejectedValue( { code: 'rest_no_route' } );
67+
it( 'sets error on generic fetch failure', async () => {
68+
apiFetch.mockRejectedValue( { message: 'Network failure' } );
6769
const { result } = renderHook( () => useAiAnswersSettings() );
6870
await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
69-
expect( result.current.isUnavailable ).toBe( true );
70-
expect( result.current.error ).toBeNull();
71+
expect( result.current.error ).toBe( 'Network failure' );
72+
expect( result.current.isUnavailable ).toBe( false );
7173
} );
74+
} );
7275

73-
it( 'sets isUnavailable on 404 status error', async () => {
74-
apiFetch.mockRejectedValue( { data: { status: 404 } } );
76+
describe( 'initial load — guidelines endpoint absent, settings fallback', () => {
77+
it( 'falls back to /wp/v2/settings on rest_no_route', async () => {
78+
apiFetch
79+
.mockRejectedValueOnce( { code: 'rest_no_route' } )
80+
.mockResolvedValueOnce( { [ SETTINGS_KEY ]: 'Answer in French.' } );
7581
const { result } = renderHook( () => useAiAnswersSettings() );
7682
await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
77-
expect( result.current.isUnavailable ).toBe( true );
83+
expect( result.current.content ).toBe( 'Answer in French.' );
84+
expect( result.current.isUnavailable ).toBe( false );
7885
} );
7986

80-
it( 'sets error on generic fetch failure', async () => {
81-
apiFetch.mockRejectedValue( { message: 'Network failure' } );
87+
it( 'falls back to /wp/v2/settings on 404 status error', async () => {
88+
apiFetch
89+
.mockRejectedValueOnce( { data: { status: 404 } } )
90+
.mockResolvedValueOnce( { [ SETTINGS_KEY ]: 'Be brief.' } );
8291
const { result } = renderHook( () => useAiAnswersSettings() );
8392
await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
84-
expect( result.current.error ).toBe( 'Network failure' );
93+
expect( result.current.content ).toBe( 'Be brief.' );
8594
expect( result.current.isUnavailable ).toBe( false );
8695
} );
96+
97+
it( 'leaves content empty when option is not set', async () => {
98+
apiFetch.mockRejectedValueOnce( { code: 'rest_no_route' } ).mockResolvedValueOnce( {} );
99+
const { result } = renderHook( () => useAiAnswersSettings() );
100+
await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
101+
expect( result.current.content ).toBe( '' );
102+
} );
103+
104+
it( 'sets isUnavailable when both endpoints fail', async () => {
105+
apiFetch
106+
.mockRejectedValueOnce( { code: 'rest_no_route' } )
107+
.mockRejectedValueOnce( { message: 'Settings unavailable' } );
108+
const { result } = renderHook( () => useAiAnswersSettings() );
109+
await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
110+
expect( result.current.isUnavailable ).toBe( true );
111+
expect( result.current.error ).toBeNull();
112+
} );
87113
} );
88114

89-
describe( 'savePersonality', () => {
115+
describe( 'savePersonality — guidelines path', () => {
90116
it( 'POSTs when postId is null', async () => {
91117
apiFetch.mockResolvedValueOnce( [] ).mockResolvedValueOnce( { id: 99 } );
92118
const { result } = renderHook( () => useAiAnswersSettings() );
@@ -162,4 +188,61 @@ describe( 'useAiAnswersSettings', () => {
162188
).toBe( 'Custom instructions.' );
163189
} );
164190
} );
191+
192+
describe( 'savePersonality — settings fallback path', () => {
193+
const setupSettingsFallback = async () => {
194+
apiFetch
195+
.mockRejectedValueOnce( { code: 'rest_no_route' } )
196+
.mockResolvedValueOnce( { [ SETTINGS_KEY ]: 'Be concise.' } );
197+
const utils = renderHook( () => useAiAnswersSettings() );
198+
await waitFor( () => expect( utils.result.current.isLoading ).toBe( false ) );
199+
return utils;
200+
};
201+
202+
it( 'POSTs to /wp/v2/settings', async () => {
203+
const { result } = await setupSettingsFallback();
204+
apiFetch.mockResolvedValueOnce( {} );
205+
206+
await act( async () => result.current.savePersonality() );
207+
208+
const saveCall = apiFetch.mock.calls[ 2 ][ 0 ];
209+
expect( saveCall.method ).toBe( 'POST' );
210+
expect( saveCall.path ).toBe( '/wp/v2/settings' );
211+
expect( saveCall.data[ SETTINGS_KEY ] ).toBe( 'Be concise.' );
212+
} );
213+
214+
it( 'sends DEFAULT_PERSONALITY when content is empty', async () => {
215+
apiFetch
216+
.mockRejectedValueOnce( { code: 'rest_no_route' } )
217+
.mockResolvedValueOnce( {} )
218+
.mockResolvedValueOnce( {} );
219+
const { result } = renderHook( () => useAiAnswersSettings() );
220+
await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
221+
222+
await act( async () => result.current.savePersonality() );
223+
224+
const saveCall = apiFetch.mock.calls[ 2 ][ 0 ];
225+
expect( saveCall.data[ SETTINGS_KEY ] ).toBe( DEFAULT_PERSONALITY );
226+
} );
227+
228+
it( 'sets saved on success', async () => {
229+
const { result } = await setupSettingsFallback();
230+
apiFetch.mockResolvedValueOnce( {} );
231+
232+
await act( async () => result.current.savePersonality() );
233+
234+
expect( result.current.saved ).toBe( true );
235+
expect( result.current.isSaving ).toBe( false );
236+
} );
237+
238+
it( 'sets error on save failure', async () => {
239+
const { result } = await setupSettingsFallback();
240+
apiFetch.mockRejectedValueOnce( { message: 'Settings save failed' } );
241+
242+
await act( async () => result.current.savePersonality() );
243+
244+
expect( result.current.error ).toBe( 'Settings save failed' );
245+
expect( result.current.saved ).toBe( false );
246+
} );
247+
} );
165248
} );

0 commit comments

Comments
 (0)