Skip to content

Commit

Permalink
AI Client: Separate AIControl UI from block logic and update messages (
Browse files Browse the repository at this point in the history
…#36967)

* add ErrorMessage component and Message stories

* fix custom footer condition

* separate AIControl component into two for separate UI and logic concerns

* changelog

* change useState import

* fix story mapping

* add BlockAIControl story

* add storybook preview api to ai client

* fix style

* rename send handler

* update story with useArgs

* add ExtensionAIControl component

* remove noop functions

* fix discard issue

* rename bannerComponent and errorComponent

* rename debug call
  • Loading branch information
dhasilva authored Apr 23, 2024
1 parent 8eb85b4 commit e7f3263
Show file tree
Hide file tree
Showing 21 changed files with 1,299 additions and 522 deletions.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

AI Client: Separate AIControl UI from block logic
1 change: 1 addition & 0 deletions projects/js-packages/ai-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"devDependencies": {
"@storybook/addon-actions": "8.0.6",
"@storybook/blocks": "8.0.6",
"@storybook/preview-api": "8.0.6",
"@storybook/react": "8.0.6",
"@types/markdown-it": "14.0.0",
"@types/turndown": "5.0.4",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* External dependencies
*/
import { PlainText } from '@wordpress/block-editor';
import classNames from 'classnames';
import React from 'react';
/**
* Internal dependencies
*/
import AiStatusIndicator from '../ai-status-indicator/index.js';
import './style.scss';
/**
* Types
*/
import type { RequestingStateProp } from '../../types.js';
import type { ReactElement } from 'react';

type AIControlProps = {
disabled?: boolean;
value: string;
placeholder?: string;
isTransparent?: boolean;
state?: RequestingStateProp;
onChange?: ( newValue: string ) => void;
banner?: ReactElement;
error?: ReactElement;
actions?: ReactElement;
message?: ReactElement;
promptUserInputRef?: React.MutableRefObject< HTMLInputElement >;
};

/**
* Base AIControl component. Contains the main structure of the control component and slots for banner, error, actions and message.
*
* @param {AIControlProps} props - Component props
* @returns {ReactElement} Rendered component
*/
export default function AIControl( {
disabled = false,
value = '',
placeholder = '',
isTransparent = false,
state = 'init',
onChange,
banner = null,
error = null,
actions = null,
message = null,
promptUserInputRef = null,
}: AIControlProps ): ReactElement {
return (
<div className="jetpack-components-ai-control__container-wrapper">
{ error }
<div className="jetpack-components-ai-control__container">
{ banner }
<div
className={ classNames( 'jetpack-components-ai-control__wrapper', {
'is-transparent': isTransparent,
} ) }
>
<AiStatusIndicator state={ state } />

<div className="jetpack-components-ai-control__input-wrapper">
<PlainText
value={ value }
onChange={ onChange }
placeholder={ placeholder }
className="jetpack-components-ai-control__input"
disabled={ disabled }
ref={ promptUserInputRef }
/>
</div>
{ actions }
</div>
{ message }
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/**
* External dependencies
*/
import { Button, ButtonGroup } from '@wordpress/components';
import { useKeyboardShortcut } from '@wordpress/compose';
import { useImperativeHandle, useRef, useEffect, useCallback, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
Icon,
closeSmall,
check,
arrowUp,
trash,
reusableBlock as regenerate,
} from '@wordpress/icons';
import debugFactory from 'debug';
import React, { forwardRef } from 'react';
/**
* Internal dependencies
*/
import { GuidelineMessage } from '../message/index.js';
import AIControl from './ai-control.js';
import './style.scss';
/**
* Types
*/
import type { RequestingStateProp } from '../../types.js';
import type { ReactElement } from 'react';

type BlockAIControlProps = {
disabled?: boolean;
value: string;
placeholder?: string;
showAccept?: boolean;
acceptLabel?: string;
showButtonLabels?: boolean;
isTransparent?: boolean;
state?: RequestingStateProp;
showGuideLine?: boolean;
customFooter?: ReactElement;
onChange?: ( newValue: string ) => void;
onSend?: ( currentValue: string ) => void;
onStop?: () => void;
onAccept?: () => void;
onDiscard?: () => void;
showRemove?: boolean;
banner?: ReactElement;
error?: ReactElement;
};

const debug = debugFactory( 'jetpack-ai-client:block-ai-control' );

/**
* BlockAIControl component. Used by the AI Assistant block, adding logic and components to the base AIControl component.
*
* @param {BlockAIControlProps} props - Component props
* @param {React.MutableRefObject} ref - Ref to the component
* @returns {ReactElement} Rendered component
*/
export function BlockAIControl(
{
disabled = false,
value = '',
placeholder = '',
showAccept = false,
acceptLabel = __( 'Accept', 'jetpack-ai-client' ),
showButtonLabels = true,
isTransparent = false,
state = 'init',
showGuideLine = false,
customFooter = null,
onChange,
onSend,
onStop,
onAccept,
onDiscard,
showRemove = false,
banner = null,
error = null,
}: BlockAIControlProps,
ref: React.MutableRefObject< HTMLInputElement >
): ReactElement {
const loading = state === 'requesting' || state === 'suggesting';
const [ editRequest, setEditRequest ] = useState( false );
const [ lastValue, setLastValue ] = useState( value || null );
const promptUserInputRef = useRef( null );

// Pass the ref to forwardRef.
useImperativeHandle( ref, () => promptUserInputRef.current );

useEffect( () => {
if ( editRequest ) {
promptUserInputRef?.current?.focus();
}
}, [ editRequest ] );

const sendHandler = useCallback( () => {
setLastValue( value );
setEditRequest( false );
onSend?.( value );
}, [ value ] );

const changeHandler = useCallback(
( newValue: string ) => {
onChange?.( newValue );
if ( state === 'init' ) {
return;
}

if ( ! lastValue ) {
// here we're coming from a one-click action
setEditRequest( newValue.length > 0 );
} else {
// here we're coming from an edit action
setEditRequest( newValue !== lastValue );
}
},
[ lastValue, state ]
);

const discardHandler = useCallback( () => {
onDiscard?.();
}, [] );

const cancelEdit = useCallback( () => {
debug( 'cancelEdit, revert to last value', lastValue );
onChange?.( lastValue || '' );
setEditRequest( false );
}, [ lastValue ] );

useKeyboardShortcut(
'mod+enter',
() => {
if ( showAccept ) {
onAccept?.();
}
},
{
target: promptUserInputRef,
}
);

useKeyboardShortcut(
'enter',
e => {
e.preventDefault();
sendHandler();
},
{
target: promptUserInputRef,
}
);

const actions = (
<>
{ ( ! showAccept || editRequest ) && (
<div className="jetpack-components-ai-control__controls-prompt_button_wrapper">
{ ! loading ? (
<>
{ editRequest && (
<Button
className="jetpack-components-ai-control__controls-prompt_button"
onClick={ cancelEdit }
variant="secondary"
label={ __( 'Cancel', 'jetpack-ai-client' ) }
>
{ showButtonLabels ? (
__( 'Cancel', 'jetpack-ai-client' )
) : (
<Icon icon={ closeSmall } />
) }
</Button>
) }

{ showRemove && ! editRequest && ! value?.length && onDiscard && (
<Button
className="jetpack-components-ai-control__controls-prompt_button"
onClick={ discardHandler }
variant="secondary"
label={ __( 'Cancel', 'jetpack-ai-client' ) }
>
{ showButtonLabels ? (
__( 'Cancel', 'jetpack-ai-client' )
) : (
<Icon icon={ closeSmall } />
) }
</Button>
) }

{ value?.length > 0 && (
<Button
className="jetpack-components-ai-control__controls-prompt_button"
onClick={ sendHandler }
variant="primary"
disabled={ ! value?.length || disabled }
label={ __( 'Send request', 'jetpack-ai-client' ) }
>
{ showButtonLabels ? (
__( 'Generate', 'jetpack-ai-client' )
) : (
<Icon icon={ arrowUp } />
) }
</Button>
) }
</>
) : (
<Button
className="jetpack-components-ai-control__controls-prompt_button"
onClick={ onStop }
variant="secondary"
label={ __( 'Stop request', 'jetpack-ai-client' ) }
>
{ showButtonLabels ? (
__( 'Stop', 'jetpack-ai-client' )
) : (
<Icon icon={ closeSmall } />
) }
</Button>
) }
</div>
) }
{ showAccept && ! editRequest && (
<div className="jetpack-components-ai-control__controls-prompt_button_wrapper">
{ ( value?.length > 0 || lastValue === null ) && (
<ButtonGroup>
<Button
className="jetpack-components-ai-control__controls-prompt_button"
label={ __( 'Discard', 'jetpack-ai-client' ) }
onClick={ discardHandler }
tooltipPosition="top"
>
<Icon icon={ trash } />
</Button>
<Button
className="jetpack-components-ai-control__controls-prompt_button"
label={ __( 'Regenerate', 'jetpack-ai-client' ) }
onClick={ () => onSend?.( value ) }
tooltipPosition="top"
disabled={ ! value?.length || value === null || disabled }
>
<Icon icon={ regenerate } />
</Button>
</ButtonGroup>
) }
<Button
className="jetpack-components-ai-control__controls-prompt_button"
onClick={ onAccept }
variant="primary"
label={ acceptLabel }
>
{ showButtonLabels ? acceptLabel : <Icon icon={ check } /> }
</Button>
</div>
) }
</>
);

const message =
showGuideLine && ! loading && ! editRequest && ( customFooter || <GuidelineMessage /> );

return (
<AIControl
disabled={ disabled || loading }
value={ value }
placeholder={ placeholder }
isTransparent={ isTransparent }
state={ state }
onChange={ changeHandler }
banner={ banner }
error={ error }
actions={ actions }
message={ message }
promptUserInputRef={ promptUserInputRef }
/>
);
}

export default forwardRef( BlockAIControl );
Loading

0 comments on commit e7f3263

Please sign in to comment.