Product Editor Dev Tools: Improved expression evaluation tooling (#48588)

* Refactor to extract ExpressionField

* Move styling of expression and result prefixes to CSS

* Layout expression field with grid

* Add buttons to expression field

* Enter edit mode and cancel

* Hook up editing in textarea

* Allow existing expression to be edited

* Make expression param optional

* Use ExpressionField to add new expression

* Hide cancel button if no callback and add updateLabel prop

* Only show error when dirty

* Placeholder for expression field

* Format result in expression field

* Expression field textarea styling

* Justify expressions at bottom

* Prevent jumping when editing expression

* Revert changes on cancel

* Remove unused CSS

* Extract ExpressionTextArea

* Extract ExpressionResult

* Auto height textarea

* Hover styling for expression

* Result type tag

* Tweak styling

* Update padding in expression field

* Changelog

* Enable scrolling of result when editing expression

* Use smaller close icon

* Fix enabling of add/edit button

* Focus text area when clicking edit button

* Put cursor at the end of text area when focusing it

* Enter edit mode when clicking on text area

* Remove expression in list

* Don't style expressions list as a flex box

* Show full result while editing expression
This commit is contained in:
Matt Sherman 2024-07-09 14:05:39 -04:00 committed by GitHub
parent 5948b0d822
commit 2957458bac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 386 additions and 77 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Product Editor Dev Tools: Improve expression evaluation tooling support.

View File

@ -0,0 +1,137 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { useEffect, useRef, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { check, closeSmall, edit, trash } from '@wordpress/icons';
import { evaluate } from '@woocommerce/expression-evaluation';
/**
* Internal dependencies
*/
import { ExpressionResult } from './expression-result';
import { ExpressionTextArea } from './expression-text-area';
function evaluateExpression(
expression: string,
evaluationContext: object | undefined
) {
let result;
let error;
try {
result = evaluate( expression, evaluationContext );
} catch ( e ) {
error = e;
}
return {
result,
error,
};
}
type ExpressionFieldProps = {
expression?: string;
evaluationContext?: object;
mode?: 'view' | 'edit';
onEnterEdit?: () => void;
onUpdate?: ( expression: string ) => void;
onCancel?: () => void;
onRemove?: () => void;
updateLabel?: string;
};
export function ExpressionField( {
expression = '',
evaluationContext,
mode = 'view',
onEnterEdit,
onUpdate,
onCancel,
onRemove,
updateLabel = __( 'Update', 'woocommerce' ),
}: ExpressionFieldProps ) {
const textAreaRef = useRef< HTMLTextAreaElement >( null );
const [ editedExpression, setEditedExpression ] = useState( expression );
useEffect( () => setEditedExpression( expression ), [ expression ] );
const { result, error } = evaluateExpression(
editedExpression,
evaluationContext
);
function handleOnClickEdit() {
const textArea = textAreaRef.current;
if ( textArea ) {
textArea.focus();
textArea.setSelectionRange(
textArea.value.length,
textArea.value.length
);
}
onEnterEdit?.();
}
function handleOnClickCancel() {
setEditedExpression( expression );
onCancel?.();
}
return (
<div
className="woocommerce-product-editor-dev-tools-expression-field"
data-mode={ mode }
>
<ExpressionTextArea
ref={ textAreaRef }
readOnly={ mode === 'view' }
expression={ editedExpression }
onChange={ setEditedExpression }
onClick={ onEnterEdit }
/>
<ExpressionResult
result={ result }
error={ error }
showIfError={ editedExpression.length > 0 }
/>
<div className="woocommerce-product-editor-dev-tools-expression-field__actions">
{ mode === 'view' ? (
<>
<Button
icon={ edit }
label={ __( 'Edit', 'woocommerce' ) }
onClick={ handleOnClickEdit }
/>
<Button
icon={ trash }
label={ __( 'Remove', 'woocommerce' ) }
onClick={ () => onRemove?.() }
/>
</>
) : (
<>
<Button
icon={ check }
label={ updateLabel }
disabled={ !! error }
onClick={ () => onUpdate?.( editedExpression ) }
/>
{ onCancel && (
<Button
icon={ closeSmall }
label={ __( 'Cancel', 'woocommerce' ) }
onClick={ handleOnClickCancel }
/>
) }
</>
) }
</div>
</div>
);
}

View File

@ -0,0 +1,34 @@
type ExpressionResultProps = {
result: any;
error: any;
showIfError: boolean;
};
export function ExpressionResult( {
result,
error,
showIfError = true,
}: ExpressionResultProps ) {
const errorString = error && showIfError ? String( error ) : '';
const resultString = error
? errorString
: JSON.stringify( result, null, 4 );
const resultTypeLabel = error ? 'Error' : typeof result;
return (
<div className="woocommerce-product-editor-dev-tools-expression-field__result">
{ showIfError && (
<>
<div className="woocommerce-product-editor-dev-tools-expression-field__result-type">
{ resultTypeLabel }
</div>
<div className="woocommerce-product-editor-dev-tools-expression-field__result-value">
{ resultString }
</div>
</>
) }
</div>
);
}

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import {
forwardRef,
useImperativeHandle,
useLayoutEffect,
useRef,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
type ExpressionTextAreaProps = {
expression?: string;
readOnly?: boolean;
onChange?: ( expression: string ) => void;
onClick?: () => void;
};
export const ExpressionTextArea = forwardRef<
HTMLTextAreaElement,
ExpressionTextAreaProps
>( ( { expression, readOnly = false, onChange, onClick }, outerRef ) => {
const textAreaRef = useRef< HTMLTextAreaElement >( null );
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
useImperativeHandle( outerRef, () => textAreaRef.current!, [] );
useLayoutEffect( () => {
const textArea = textAreaRef.current;
if ( ! textArea || textArea.scrollHeight < 1 ) return;
// Have to first set the height to auto and then set it to the scrollHeight
// to allow the textarea to shrink when lines are removed
textArea.style.height = 'auto';
textArea.style.height = `${ textArea.scrollHeight }px`;
}, [ expression ] );
return (
<textarea
ref={ textAreaRef }
className="woocommerce-product-editor-dev-tools-expression-field__expression"
rows={ 1 }
readOnly={ readOnly }
value={ expression }
placeholder={ __( 'Enter an expression', 'woocommerce' ) }
onChange={ ( event ) => onChange?.( event.target.value ) }
onClick={ () => onClick?.() }
/>
);
} );

View File

@ -1,79 +1,88 @@
/**
* External dependencies
*/
import { Product } from '@woocommerce/data';
import { evaluate } from '@woocommerce/expression-evaluation';
import { Button } from '@wordpress/components';
import { useState } from 'react';
import { useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { ExpressionField } from './expression-field';
type ExpressionItem = {
expression: string;
mode: 'view' | 'edit';
};
export function ExpressionsPanel( {
evaluationContext,
}: {
evaluationContext: {
postType: string;
editedProduct: Product;
};
evaluationContext?: object;
} ) {
const [ expressions, setExpressions ] = useState< Array< string > >( [] );
const [ expressionToAdd, setExpressionToAdd ] = useState< string >( '' );
const [ expressionItems, setExpressionItems ] = useState<
ExpressionItem[]
>( [] );
const handleExpressionToAddChange = (
event: React.ChangeEvent< HTMLTextAreaElement >
) => {
setExpressionToAdd( event.target.value );
};
function enterEditMode( index: number ) {
const newItems = [ ...expressionItems ];
newItems[ index ].mode = 'edit';
setExpressionItems( newItems );
}
const addExpression = () => {
setExpressions( [ ...expressions, expressionToAdd ] );
setExpressionToAdd( '' );
};
function cancelEdit( index: number ) {
const newItems = [ ...expressionItems ];
newItems[ index ].mode = 'view';
setExpressionItems( newItems );
}
const evaluateExpression = ( expression: string ) => {
let result;
function addExpression( expression: string ) {
setExpressionItems( [
...expressionItems,
{ expression, mode: 'view' },
] );
}
try {
result = evaluate( expression, evaluationContext );
} catch ( error ) {
result = error;
}
function updateExpression( index: number, expression: string ) {
const newItems = [ ...expressionItems ];
newItems[ index ].expression = expression;
newItems[ index ].mode = 'view';
setExpressionItems( newItems );
}
return String( result );
};
function removeExpression( index: number ) {
return () => {
const newItems = expressionItems.filter(
( item, i ) => i !== index
);
setExpressionItems( newItems );
};
}
return (
<div className="woocommerce-product-editor-dev-tools-expressions">
{ expressions.length === 0 && (
<div className="woocommerce-product-editor-dev-tools-expressions-list empty">
Enter an expression to evaluate below.
</div>
) }
{ expressions.length > 0 && (
<ul className="woocommerce-product-editor-dev-tools-expressions-list">
{ expressions.map( ( expression, index ) => (
<li key={ index }>
<div>
<span className="woocommerce-product-editor-dev-tools-expressions-list-prompt">
&gt;
</span>{ ' ' }
{ expression }
</div>
<div>
<span className="woocommerce-product-editor-dev-tools-expressions-list-prompt">
&lt;
</span>{ ' ' }
{ evaluateExpression( expression ) }
</div>
</li>
) ) }
</ul>
) }
<div className="woocommerce-product-editor-dev-tools-expression-editor">
<textarea
value={ expressionToAdd }
onChange={ handleExpressionToAddChange }
<ul className="woocommerce-product-editor-dev-tools-expressions-list">
{ expressionItems.map( ( expressionItem, index ) => (
<li key={ index }>
<ExpressionField
expression={ expressionItem.expression }
evaluationContext={ evaluationContext }
mode={ expressionItem.mode }
onEnterEdit={ () => enterEditMode( index ) }
onCancel={ () => cancelEdit( index ) }
onUpdate={ ( expression ) =>
updateExpression( index, expression )
}
onRemove={ removeExpression( index ) }
/>
</li>
) ) }
<li key={ expressionItems.length + 1 }>
<ExpressionField
evaluationContext={ evaluationContext }
mode={ 'edit' }
onUpdate={ ( expression ) => addExpression( expression ) }
updateLabel={ __( 'Add', 'woocommerce' ) }
/>
<Button onClick={ addExpression }>Add</Button>
</div>
</div>
</li>
</ul>
);
}

View File

@ -108,20 +108,11 @@
font-family: var(--woocommerce-product-editor-dev-tools-code-font-family);
}
.woocommerce-product-editor-dev-tools-expressions {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
}
.woocommerce-product-editor-dev-tools-expressions-list {
flex: 1;
height: 100%;
margin: 0;
padding: var(--woocommerce-product-editor-dev-tools-gap);
@ -131,12 +122,10 @@
padding-bottom: var(--woocommerce-product-editor-dev-tools-gap);
font-family: var(--woocommerce-product-editor-dev-tools-code-font-family);
}
&.empty {
display: flex;
align-items: center;
justify-content: center;
&:last-child {
padding-bottom: 0;
}
}
}
@ -144,6 +133,91 @@
color: var(--woocommerce-product-editor-dev-tools-border-color);
}
.woocommerce-product-editor-dev-tools-expression-field {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"expression actions"
"result actions";
gap: var(--woocommerce-product-editor-dev-tools-gap-small);
}
.woocommerce-product-editor-dev-tools-expression-field__expression {
grid-area: expression;
overflow-y: hidden;
resize: none;
border: none;
border-radius: 0;
padding: var(--woocommerce-product-editor-dev-tools-gap-small);
background-color: var(--woocommerce-product-editor-dev-tools-border-color);
color: white;
font-family: var(--woocommerce-product-editor-dev-tools-code-font-family);
&[readonly] {
background-color: transparent;
color: var(--woocommerce-product-editor-dev-tools-text-color);
}
}
.woocommerce-product-editor-dev-tools-expression-field[data-mode='view']:hover
.woocommerce-product-editor-dev-tools-expression-field__expression {
outline: 1px solid var(--woocommerce-product-editor-dev-tools-border-color);
}
.woocommerce-product-editor-dev-tools-expression-field__result {
grid-area: result;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: var(--woocommerce-product-editor-dev-tools-gap-small);
}
.woocommerce-product-editor-dev-tools-expression-field__result-type {
border: 1px solid var(--woocommerce-product-editor-dev-tools-border-color);
border-radius: 5px;
padding: 0px 5px;
font-size: .75em;
text-transform: uppercase;
}
.woocommerce-product-editor-dev-tools-expression-field__result-value {
flex: 1;
font-family: var(--woocommerce-product-editor-dev-tools-code-font-family);
white-space: pre-wrap;
overflow-y: scroll;
height: 100%;
}
.woocommerce-product-editor-dev-tools-expression-field__actions {
grid-area: actions;
button {
color: var(--woocommerce-product-editor-dev-tools-text-color);
}
}
.woocommerce-product-editor-dev-tools-expression-field[data-mode='view']:not(:hover)
.woocommerce-product-editor-dev-tools-expression-field__actions {
button {
color: var(--woocommerce-product-editor-dev-tools-border-color);
}
}
.woocommerce-product-editor-dev-tools-expression-editor {
display: flex;
flex-direction: row;