-
-
-
-
{props.onRenderValueField(props.schemaKey)}
+ <>
+
+
+
+
+
{props.onRenderValueField(props.schemaKey)}
+
+
{renderMenuButton()}
-
{renderMenuButton()}
-
+ {props?.renderDescriptionField && (
+
+
+
+ )}
+ >
);
};
diff --git a/libs/designer-ui/src/lib/floatingactionmenu/floatingactionmenuoutputs/__test__/floatingactionmenuoutputs.spec.tsx b/libs/designer-ui/src/lib/floatingactionmenu/floatingactionmenuoutputs/__test__/floatingactionmenuoutputs.spec.tsx
new file mode 100644
index 00000000000..a1ab98dc68d
--- /dev/null
+++ b/libs/designer-ui/src/lib/floatingactionmenu/floatingactionmenuoutputs/__test__/floatingactionmenuoutputs.spec.tsx
@@ -0,0 +1,402 @@
+import React from 'react';
+import { FloatingActionMenuOutputs, type FloatingActionMenuOutputsProps, type FloatingActionMenuOutputViewModel } from '..';
+import * as ReactShallowRenderer from 'react-test-renderer/shallow';
+import { describe, vi, beforeEach, afterEach, it, expect } from 'vitest';
+import { render, fireEvent, act } from '@testing-library/react';
+import { TokenPickerMode } from '../../../tokenpicker';
+import { ValueSegment } from '@microsoft/logic-apps-shared';
+import { ChangeState } from '../../../editor/base';
+
+describe('ui/floatingactionmenuoutputs', () => {
+ let minimal: FloatingActionMenuOutputsProps, renderer: ReactShallowRenderer.ShallowRenderer;
+
+ beforeEach(() => {
+ minimal = {
+ supportedTypes: ['text', 'file', 'email', 'boolean', 'number', 'date'],
+ initialValue: [
+ {
+ id: '00000000-0000-0000-0000-000000000001',
+ type: 'literal',
+ value: '{"type": "object", "properties": {}}',
+ },
+ ],
+ onChange: () => {},
+ editorViewModel: {
+ schema: {
+ type: 'object',
+ properties: {
+ text: {
+ title: 'Text',
+ description: 'Enter a description of the output',
+ type: 'string',
+ 'x-ms-content-hint': 'TEXT',
+ 'x-ms-dynamically-added': true,
+ },
+ },
+ },
+ outputValueSegmentsMap: {
+ text: [
+ {
+ id: '00000000-0000-0000-0000-000000000000',
+ type: 'literal',
+ value: 'response',
+ },
+ ],
+ },
+ },
+ basePlugins: {
+ tokens: true,
+ },
+ tokenPickerButtonProps: {
+ location: 'right',
+ },
+ getTokenPicker: (
+ _editorId: string,
+ _labelId: string,
+ _tokenPickerMode?: TokenPickerMode,
+ _valueType?: string,
+ _tokenClickedCallback?: (token: ValueSegment) => void
+ ) => <>>,
+ hideValidationErrors: (_newState: ChangeState) => {},
+ includeOutputDescription: true,
+ };
+ renderer = ReactShallowRenderer.createRenderer();
+ });
+
+ afterEach(() => {
+ renderer.unmount();
+ });
+
+ it('should render', () => {
+ renderer.render(
);
+ const output = renderer.getRenderOutput();
+ expect(output).toBeDefined();
+ });
+
+ it('should throw expected validation exception if editorViewModel?.schema?.properties is undefined', () => {
+ const minimalWithUndefinedSchemaProperties = {
+ ...minimal,
+ editorViewModel: {
+ schema: {
+ type: 'object',
+ properties: undefined,
+ },
+ } as unknown as FloatingActionMenuOutputViewModel,
+ };
+
+ try {
+ renderer.render(
);
+ expect(false).toBe(true);
+ } catch (e) {
+ expect(e.code).toBe('InvalidParameters');
+ expect(e.message).toBe('default value needed for floatingActionMenuOutputs.');
+ }
+ });
+
+ it('should call on change with expected object if title changes', () => {
+ const onChange = vi.fn();
+ const { getByPlaceholderText } = render(
);
+ const newText = 'New text';
+ fireEvent.change(getByPlaceholderText('Enter a name'), { target: { value: newText } });
+ const expectedOnChange = {
+ value: minimal.initialValue,
+ viewModel: {
+ schema: {
+ type: 'object',
+ properties: {
+ text: {
+ title: 'New text',
+ description: 'Enter a description of the output',
+ type: 'string',
+ 'x-ms-content-hint': 'TEXT',
+ 'x-ms-dynamically-added': true,
+ },
+ },
+ },
+ outputValueSegmentsMap: {
+ text: [
+ {
+ id: '00000000-0000-0000-0000-000000000000',
+ type: 'literal',
+ value: 'response',
+ },
+ ],
+ },
+ },
+ };
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining(expectedOnChange));
+ });
+
+ it('should call on change with expected object if description changes', () => {
+ const onChange = vi.fn();
+ const { getByPlaceholderText } = render(
);
+ const newText = 'New text';
+ fireEvent.change(getByPlaceholderText('Enter a description of the output'), { target: { value: newText } });
+ const expectedOnChange = {
+ value: minimal.initialValue,
+ viewModel: {
+ schema: {
+ type: 'object',
+ properties: {
+ text: {
+ title: 'Text',
+ description: 'New text',
+ type: 'string',
+ 'x-ms-content-hint': 'TEXT',
+ 'x-ms-dynamically-added': true,
+ },
+ },
+ },
+ outputValueSegmentsMap: {
+ text: [
+ {
+ id: '00000000-0000-0000-0000-000000000000',
+ type: 'literal',
+ value: 'response',
+ },
+ ],
+ },
+ },
+ };
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining(expectedOnChange));
+ });
+
+ it('should call on change with expected object if parameter deleted changes', async () => {
+ const onChange = vi.fn();
+ const { getByTitle, findByText } = render(
);
+ fireEvent.click(getByTitle('Menu'));
+ const deleteButton = await findByText('Delete');
+ fireEvent.click(deleteButton);
+ const expectedOnChange = {
+ value: minimal.initialValue,
+ viewModel: {
+ schema: {
+ type: 'object',
+ properties: {},
+ },
+ outputValueSegmentsMap: {},
+ },
+ };
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining(expectedOnChange));
+ });
+
+ it('should call on change with expected object if text menu item selected', async () => {
+ const initialEditorViewModel = {
+ schema: {
+ type: 'object',
+ properties: {},
+ },
+ outputValueSegmentsMap: {},
+ };
+ const onChange = vi.fn();
+ const { getByText, findByRole } = render(
+
+ );
+ fireEvent.click(getByText('Add an output'));
+ const booleanMenuItem = await findByRole('button', { name: 'Yes/No' });
+ fireEvent.click(booleanMenuItem);
+ const expectedOnChange = {
+ value: minimal.initialValue,
+ viewModel: {
+ schema: {
+ type: 'object',
+ properties: {
+ boolean: {
+ title: '',
+ description: '',
+ format: undefined,
+ type: 'boolean',
+ 'x-ms-content-hint': 'BOOLEAN',
+ 'x-ms-dynamically-added': true,
+ },
+ },
+ },
+ outputValueSegmentsMap: {},
+ },
+ };
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining(expectedOnChange));
+ });
+
+ it('should call on change with expected object if boolean menu item selected', async () => {
+ const initialEditorViewModel = {
+ schema: {
+ type: 'object',
+ properties: {},
+ },
+ outputValueSegmentsMap: {},
+ };
+ const onChange = vi.fn();
+ const { getByText, findByRole } = render(
+
+ );
+ fireEvent.click(getByText('Add an output'));
+ const booleanMenuItem = await findByRole('button', { name: 'Yes/No' });
+ fireEvent.click(booleanMenuItem);
+ const expectedOnChange = {
+ value: minimal.initialValue,
+ viewModel: {
+ schema: {
+ type: 'object',
+ properties: {
+ boolean: {
+ title: '',
+ description: '',
+ type: 'boolean',
+ 'x-ms-content-hint': 'BOOLEAN',
+ 'x-ms-dynamically-added': true,
+ },
+ },
+ },
+ outputValueSegmentsMap: {},
+ },
+ };
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining(expectedOnChange));
+ });
+
+ it('should call on change with expected object if number menu item selected', async () => {
+ const initialEditorViewModel = {
+ schema: {
+ type: 'object',
+ properties: {},
+ },
+ outputValueSegmentsMap: {},
+ };
+ const onChange = vi.fn();
+ const { getByText, findByRole } = render(
+
+ );
+ fireEvent.click(getByText('Add an output'));
+ const numberMenuItem = await findByRole('button', { name: 'Number' });
+ fireEvent.click(numberMenuItem);
+ const expectedOnChange = {
+ value: minimal.initialValue,
+ viewModel: {
+ schema: {
+ type: 'object',
+ properties: {
+ number: {
+ title: '',
+ description: '',
+ type: 'number',
+ 'x-ms-content-hint': 'NUMBER',
+ 'x-ms-dynamically-added': true,
+ },
+ },
+ },
+ outputValueSegmentsMap: {},
+ },
+ };
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining(expectedOnChange));
+ });
+
+ it('should call on change with expected object if date menu item selected', async () => {
+ const initialEditorViewModel = {
+ schema: {
+ type: 'object',
+ properties: {},
+ },
+ outputValueSegmentsMap: {},
+ };
+ const onChange = vi.fn();
+ const { getByText, findByRole } = render(
+
+ );
+ fireEvent.click(getByText('Add an output'));
+ const dateMenuItem = await findByRole('button', { name: 'Date' });
+ fireEvent.click(dateMenuItem);
+ const expectedOnChange = {
+ value: minimal.initialValue,
+ viewModel: {
+ schema: {
+ type: 'object',
+ properties: {
+ date: {
+ title: '',
+ description: '',
+ type: 'string',
+ format: 'date',
+ 'x-ms-content-hint': 'DATE',
+ 'x-ms-dynamically-added': true,
+ },
+ },
+ },
+ outputValueSegmentsMap: {},
+ },
+ };
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining(expectedOnChange));
+ });
+
+ it('should call on change with expected object if email menu item selected', async () => {
+ const initialEditorViewModel = {
+ schema: {
+ type: 'object',
+ properties: {},
+ },
+ outputValueSegmentsMap: {},
+ };
+ const onChange = vi.fn();
+ const { getByText, findByRole } = render(
+
+ );
+ fireEvent.click(getByText('Add an output'));
+ const emailMenuItem = await findByRole('button', { name: 'Email' });
+ fireEvent.click(emailMenuItem);
+ const expectedOnChange = {
+ value: minimal.initialValue,
+ viewModel: {
+ schema: {
+ type: 'object',
+ properties: {
+ email: {
+ title: '',
+ description: '',
+ type: 'string',
+ format: 'email',
+ 'x-ms-content-hint': 'EMAIL',
+ 'x-ms-dynamically-added': true,
+ },
+ },
+ },
+ outputValueSegmentsMap: {},
+ },
+ };
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining(expectedOnChange));
+ });
+
+ it('should call on change with expected object if file menu item selected', async () => {
+ const initialEditorViewModel = {
+ schema: {
+ type: 'object',
+ properties: {},
+ },
+ outputValueSegmentsMap: {},
+ };
+ const onChange = vi.fn();
+ const { getByText, findByRole } = render(
+
+ );
+ fireEvent.click(getByText('Add an output'));
+ const fileMenuItem = await findByRole('button', { name: 'File' });
+ fireEvent.click(fileMenuItem);
+ const expectedOnChange = {
+ value: minimal.initialValue,
+ viewModel: {
+ schema: {
+ type: 'object',
+ properties: {
+ file: {
+ title: '',
+ description: '',
+ type: 'string',
+ format: 'byte',
+ 'x-ms-content-hint': 'FILE',
+ 'x-ms-dynamically-added': true,
+ },
+ },
+ },
+ outputValueSegmentsMap: {},
+ },
+ };
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining(expectedOnChange));
+ });
+});
diff --git a/libs/designer-ui/src/lib/floatingactionmenu/floatingactionmenuoutputs/index.tsx b/libs/designer-ui/src/lib/floatingactionmenu/floatingactionmenuoutputs/index.tsx
index 663bb74bd24..bbb9ca3a27f 100644
--- a/libs/designer-ui/src/lib/floatingactionmenu/floatingactionmenuoutputs/index.tsx
+++ b/libs/designer-ui/src/lib/floatingactionmenu/floatingactionmenuoutputs/index.tsx
@@ -14,6 +14,7 @@ import { useIntl } from 'react-intl';
type DynamicallyAddedParameterOutputsProperties = {
type: string;
title: string;
+ description?: string;
format?: string;
'x-ms-content-hint'?: DynamicallyAddedParameterTypeType; // May not be present if opening a workflow from v1 designer
'x-ms-dynamically-added': boolean;
@@ -27,7 +28,7 @@ export type FloatingActionMenuOutputViewModel = {
outputValueSegmentsMap: Record
;
};
-type FloatingActionMenuOutputsProps = {
+export type FloatingActionMenuOutputsProps = {
supportedTypes: string[];
initialValue: ValueSegment[];
onChange?: ChangeHandler;
@@ -36,6 +37,7 @@ type FloatingActionMenuOutputsProps = {
tokenPickerButtonProps: TokenPickerButtonEditorProps | undefined;
getTokenPicker: GetTokenPickerHandler;
hideValidationErrors: ChangeHandler | undefined;
+ includeOutputDescription?: boolean;
};
export const FloatingActionMenuOutputs = (props: FloatingActionMenuOutputsProps): JSX.Element => {
@@ -54,17 +56,38 @@ export const FloatingActionMenuOutputs = (props: FloatingActionMenuOutputsProps)
const onDynamicallyAddedParameterTitleChange = (schemaKey: string, newValue: string | undefined): void => {
const { onChange } = props;
- if (onChange) {
- const viewModel = clone(props.editorViewModel);
- viewModel.schema.properties[schemaKey].title = newValue || '';
+ if (!onChange) {
+ return;
+ }
+
+ const viewModel = clone(props.editorViewModel);
+ if (viewModel?.schema?.properties?.[schemaKey]) {
+ viewModel.schema.properties[schemaKey].title = newValue ?? '';
+ onChange({ value: props.initialValue, viewModel });
+ }
+ };
+
+ const onDynamicallyAddedParameterDescriptionChange = (schemaKey: string, newValue: string | undefined): void => {
+ const { onChange } = props;
+ if (!onChange) {
+ return;
+ }
+
+ const viewModel = clone(props.editorViewModel);
+ if (viewModel.schema?.properties?.[schemaKey]) {
+ viewModel.schema.properties[schemaKey].description = newValue ?? '';
onChange({ value: props.initialValue, viewModel });
}
};
const onDynamicallyAddedParameterDelete = (schemaKey: string): void => {
const { onChange } = props;
- if (onChange) {
- const viewModel = clone(props.editorViewModel);
+ if (!onChange) {
+ return;
+ }
+
+ const viewModel = clone(props.editorViewModel);
+ if (viewModel?.schema?.properties && viewModel?.outputValueSegmentsMap) {
delete viewModel.schema.properties[schemaKey];
delete viewModel.outputValueSegmentsMap[schemaKey];
onChange({ value: props.initialValue, viewModel });
@@ -79,8 +102,12 @@ export const FloatingActionMenuOutputs = (props: FloatingActionMenuOutputsProps)
});
const onDynamicallyAddedParameterValueChange = (schemaKey: string, newValue: ValueSegment[]) => {
const { onChange } = props;
- if (onChange) {
- const viewModel = clone(props.editorViewModel);
+ if (!onChange) {
+ return;
+ }
+
+ const viewModel = clone(props.editorViewModel);
+ if (viewModel?.outputValueSegmentsMap) {
viewModel.outputValueSegmentsMap[schemaKey] = newValue;
onChange({ value: props.initialValue, viewModel });
}
@@ -92,7 +119,7 @@ export const FloatingActionMenuOutputs = (props: FloatingActionMenuOutputsProps)
placeholder={placeholder}
basePlugins={props.basePlugins}
readonly={false}
- initialValue={props.editorViewModel.outputValueSegmentsMap[schemaKey] || []}
+ initialValue={props.editorViewModel?.outputValueSegmentsMap?.[schemaKey] ?? []}
tokenPickerButtonProps={props.tokenPickerButtonProps}
editorBlur={(newState: ChangeState) => onDynamicallyAddedParameterValueChange(schemaKey, newState.value)}
getTokenPicker={props.getTokenPicker}
@@ -102,14 +129,23 @@ export const FloatingActionMenuOutputs = (props: FloatingActionMenuOutputsProps)
);
};
- const dynamicParameterProps: Pick[] = Object.entries(
- props.editorViewModel.schema.properties
+ const dynamicParameterProps: Pick[] = Object.entries(
+ props.editorViewModel?.schema?.properties ?? {}
)
.filter(([_key, config]) => {
return config?.['x-ms-dynamically-added'];
})
.map(([key, config]) => {
const contentHint = inferContextHintFromProperties(config);
+ if (props?.includeOutputDescription) {
+ return {
+ schemaKey: key,
+ icon: getIconForDynamicallyAddedParameterType(contentHint),
+ title: config.title,
+ description: config.description,
+ };
+ }
+
return {
schemaKey: key,
icon: getIconForDynamicallyAddedParameterType(contentHint),
@@ -119,9 +155,12 @@ export const FloatingActionMenuOutputs = (props: FloatingActionMenuOutputsProps)
const onMenuItemSelected = (item: FloatingActionMenuItem): void => {
const { onChange } = props;
- if (onChange) {
- const viewModel = clone(props.editorViewModel);
+ if (!onChange) {
+ return;
+ }
+ const viewModel = clone(props.editorViewModel);
+ if (viewModel?.schema?.properties) {
// Effectively a placeholder key that will be renmaed to 'title' if valid at EditorViewModel serialization.
const schemaKey = generateDynamicParameterKey(Object.keys(viewModel.schema.properties), item.type);
@@ -156,13 +195,25 @@ export const FloatingActionMenuOutputs = (props: FloatingActionMenuOutputsProps)
break;
}
}
- viewModel.schema.properties[schemaKey] = {
- title: '',
- type,
- format,
- 'x-ms-content-hint': item.type,
- 'x-ms-dynamically-added': true,
- };
+
+ if (props?.includeOutputDescription) {
+ viewModel.schema.properties[schemaKey] = {
+ title: '',
+ description: '',
+ type,
+ format,
+ 'x-ms-content-hint': item.type,
+ 'x-ms-dynamically-added': true,
+ };
+ } else {
+ viewModel.schema.properties[schemaKey] = {
+ title: '',
+ type,
+ format,
+ 'x-ms-content-hint': item.type,
+ 'x-ms-dynamically-added': true,
+ };
+ }
onChange({ value: props.initialValue, viewModel });
}
@@ -184,6 +235,12 @@ export const FloatingActionMenuOutputs = (props: FloatingActionMenuOutputsProps)
description: 'Placeholder for output title field',
});
+ const descriptionPlaceholder = intl.formatMessage({
+ defaultMessage: 'Enter a description of the output',
+ id: 'Nbl3zN',
+ description: 'Placeholder for output description field',
+ });
+
return (
- {dynamicParameterProps.map((props) => (
+ {dynamicParameterProps.map((dynamicallyAddedParameterProps) => (
))}
diff --git a/libs/designer-ui/src/lib/settings/settingsection/settingTokenField.tsx b/libs/designer-ui/src/lib/settings/settingsection/settingTokenField.tsx
index a7c045c78fa..e8347869db3 100644
--- a/libs/designer-ui/src/lib/settings/settingsection/settingTokenField.tsx
+++ b/libs/designer-ui/src/lib/settings/settingsection/settingTokenField.tsx
@@ -286,6 +286,7 @@ export const TokenField = ({
tokenPickerButtonProps={tokenpickerButtonProps}
getTokenPicker={getTokenPicker}
hideValidationErrors={hideValidationErrors}
+ includeOutputDescription={editorOptions?.includeOutputDescription}
/>
) : (