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:
parent
5948b0d822
commit
2957458bac
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Product Editor Dev Tools: Improve expression evaluation tooling support.
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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?.() }
|
||||
/>
|
||||
);
|
||||
} );
|
|
@ -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">
|
||||
>
|
||||
</span>{ ' ' }
|
||||
{ expression }
|
||||
</div>
|
||||
<div>
|
||||
<span className="woocommerce-product-editor-dev-tools-expressions-list-prompt">
|
||||
<
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue