Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('TextModalityEditor', () => {
it('should render the value if it is not a template reference', async () => {
const { findByText } = render(
<TextModalityEditor
focusOnMount={false}
lgOption={{ fileId: '', templateId: 'Activity' }}
removeModalityDisabled={false}
response={{ kind: 'Text', value: ['hello world'], valueType: 'direct' } as TextStructuredResponseItem}
Expand All @@ -30,6 +31,7 @@ describe('TextModalityEditor', () => {
it('should render items from template if the value is a template reference', async () => {
const { findByText, queryByText } = render(
<TextModalityEditor
focusOnMount={false}
lgOption={{ fileId: '', templateId: 'Activity' }}
lgTemplates={[{ name: 'Activity_text', body: '- variation1\n- variation2\n- variation3', parameters: [] }]}
removeModalityDisabled={false}
Expand Down
35 changes: 30 additions & 5 deletions Composer/packages/lib/code-editor/src/lg/hooks/useStringArray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { ArrayBasedStructuredResponseItem, PartialStructuredResponse } from '../
import { getTemplateId } from '../../utils/structuredResponse';
import { LGOption } from '../../utils/types';

const multiLineBlockSymbol = '```';

const getInitialItems = <T extends ArrayBasedStructuredResponseItem>(
response: T,
lgTemplates?: readonly LgTemplate[],
Expand All @@ -16,10 +18,30 @@ const getInitialItems = <T extends ArrayBasedStructuredResponseItem>(
const templateId = getTemplateId(response);
const template = lgTemplates?.find(({ name }) => name === templateId);
return response?.value && template?.body
? template?.body?.replace(/- /g, '').split(/\r?\n/g) || []
? template?.body
// Split by non-escaped -
// eslint-disable-next-line security/detect-unsafe-regex
?.split(/(?<!\\)- /g)
// Ignore empty or newline strings
.filter((s) => s !== '' && s !== '\n')
.map((s) => s.replace(/\r?\n$/g, ''))
// Remove LG template multiline block symbol
.map((s) => s.replace(/```/g, '')) || []
: response?.value || (focusOnMount ? [''] : []);
};

const fixMultilineItems = (items: string[]) => {
return items.map((item) => {
if (/\r?\n/g.test(item)) {
// Escape all un-escaped -
// eslint-disable-next-line security/detect-unsafe-regex
return `${multiLineBlockSymbol}${item.replace(/(?<!\\)-/g, '\\-')}${multiLineBlockSymbol}`;
}

return item;
});
};

export const useStringArray = <T extends ArrayBasedStructuredResponseItem>(
kind: 'Text' | 'Speak',
structuredResponse: T,
Expand All @@ -45,18 +67,21 @@ export const useStringArray = <T extends ArrayBasedStructuredResponseItem>(
const onChange = React.useCallback(
(newItems: string[]) => {
setItems(newItems);
// Fix variations that are multiline
// If only one item but it's multiline, still use helper LG template
const fixedNewItems = fixMultilineItems(newItems);
const id = templateId || `${lgOption?.templateId}_${newTemplateNameSuffix}`;
if (!newItems.length) {
if (!fixedNewItems.length) {
setTemplateId(id);
onUpdateResponseTemplate({ [kind]: { kind, value: [], valueType: 'direct' } });
onRemoveTemplate(id);
} else if (newItems.length === 1 && lgOption?.templateId) {
onUpdateResponseTemplate({ [kind]: { kind, value: newItems, valueType: 'direct' } });
} else if (fixedNewItems.length === 1 && !/\r?\n/g.test(fixedNewItems[0]) && lgOption?.templateId) {
onUpdateResponseTemplate({ [kind]: { kind, value: fixedNewItems, valueType: 'direct' } });
onTemplateChange(id, '');
} else {
setTemplateId(id);
onUpdateResponseTemplate({ [kind]: { kind, value: [`\${${id}()}`], valueType: 'template' } });
onTemplateChange(id, newItems.map((item) => `- ${item}`).join('\n'));
onTemplateChange(id, fixedNewItems.map((item) => `- ${item}`).join('\n'));
}
},
[kind, newTemplateNameSuffix, lgOption, templateId, onRemoveTemplate, onTemplateChange, onUpdateResponseTemplate]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,27 @@ export const StringArrayEditor = React.memo(
useEffect(() => {
const keydownHandler = (e: KeyboardEvent) => {
if (submitKeys.includes(e.key)) {
setCalloutTargetElement(null);

const filteredItems = items.filter(Boolean);
// Allow multiline via shift+Enter
if (e.key === 'Enter' && e.shiftKey) {
return;
}

setCalloutTargetElement(null);
// Filter our empty or newline strings
const filteredItems = items.filter((s) => s !== '' && s !== '\n');
if (e.key === 'Enter' && containerRef.current?.contains(e.target as Node)) {
onChange([...filteredItems, '']);
setCurrentIndex(filteredItems.length);
// If the value is not filtered, go to the next entry
// Otherwise cancel editing
if (items.length === filteredItems.length) {
e.preventDefault();
onChange([...filteredItems, '']);
setCurrentIndex(filteredItems.length);
} else {
onChange(filteredItems);
setCurrentIndex(null);
}
} else {
setCurrentIndex(null);

// Remove empty variations only if necessary
if (items.length !== filteredItems.length) {
onChange(filteredItems);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ const TextViewItem = React.memo(
onFocus={focus}
>
<Text styles={displayTextStyles} variant="small">
{onRenderDisplayText?.() ?? value}
{onRenderDisplayText?.() ?? value.replace(/\r?\n/g, '↵')}
</Text>
</Stack>
<RemoveIcon className={removeIconClassName} iconProps={{ iconName: 'Trash' }} tabIndex={-1} onClick={remove} />
Expand Down Expand Up @@ -197,18 +197,6 @@ const TextFieldItem = React.memo(({ value, onShowCallout, onChange }: TextFieldI
[onShowCallout]
);

React.useEffect(() => {
if (inputRef.current && inputRef.current.value !== value) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(inputRef.current, value);
const inputEvent = new Event('input', { bubbles: true });
inputRef.current.dispatchEvent(inputEvent);
}
}
}, [value]);

return (
<div ref={containerRef}>
<Input
Expand All @@ -218,6 +206,7 @@ const TextFieldItem = React.memo(({ value, onShowCallout, onChange }: TextFieldI
defaultValue={value}
resizable={false}
styles={textFieldStyles}
value={value}
onChange={onChange}
onClick={click}
onFocus={focus}
Expand Down