Skip to content
Open
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
8 changes: 8 additions & 0 deletions Localize/lang/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,7 @@
"OFXJe0": "Tools",
"OH9xlX": "10",
"OIOexo": "On these days",
"OMW+Rg": "Saving…",
"OMbXig": "Name",
"OMr20n": "Sandbox Configuration (Optional)",
"OMuMCI": "Workflow properties",
Expand Down Expand Up @@ -1547,6 +1548,7 @@
"SToblZ": "Configure parameters for this node",
"SUaXux": "These are the connection services this template depends on. When users deploy a workflow using this template, they’ll be prompted to create connections to the services it uses.",
"SUo94t": "Add files",
"SUqh1Y": "Save",
"SXb47U": "{minutes}m",
"SY04wn": "Required. The name of the action with a form-data or form-encoded response.",
"SY9ptd": "Each action has parameters that accept input. Check the default input sources and make any necessary changes to meet your scenario.",
Expand Down Expand Up @@ -3297,6 +3299,7 @@
"_OFXJe0.comment": "Label for allowed tools dropdown",
"_OH9xlX.comment": "Hour of the day",
"_OIOexo.comment": "Label for schedule days",
"_OMW+Rg.comment": "Label shown while saving the edited action code",
"_OMbXig.comment": "Label for the name input",
"_OMr20n.comment": "Title for sandbox configuration section",
"_OMuMCI.comment": "Header text for workflow properties",
Expand Down Expand Up @@ -3510,6 +3513,7 @@
"_SToblZ.comment": "Parameters tab description",
"_SUaXux.comment": "The description for the connections tab",
"_SUo94t.comment": "Title for add files panel",
"_SUqh1Y.comment": "Button to save the edited action code",
"_SXb47U.comment": "This is a period in time in seconds. {minutes} is replaced by the number and m is an abbreviation of minutes",
"_SY04wn.comment": "Required string parameter to identify action name for formDataMultiValues function",
"_SY9ptd.comment": "Description for the actions section",
Expand Down Expand Up @@ -4158,6 +4162,7 @@
"_eXWIo2.comment": "Description for parameter default value field",
"_eXcejw.comment": "Running status",
"_eaEXYa.comment": "Checkbox text for the filter representing all items",
"_eaI2mF.comment": "Validation error shown when the edited action code is not valid JSON",
"_eagv8j.comment": "Create logic app workspace text.",
"_eb91v1.comment": "Header for the change connection panel",
"_ec13/p.comment": "Link text to learn more about Independent Publisher connectors",
Expand Down Expand Up @@ -4469,6 +4474,7 @@
"_kSXjTx.comment": "Assertion field no description text",
"_kU4VfD.comment": "Choice group first choice: Stateful Type",
"_kVwJXt.comment": "Time zone value ",
"_kWpQvf.comment": "Button to discard edits made to the action code",
"_kXn5e0.comment": "Chabot input placeholder text",
"_kZCX7t.comment": "Day of the week",
"_kZk/Ed.comment": "ARIA label text for the download link. Do not remove the double single quotes around the display name, as it is needed to wrap the placeholder text.",
Expand Down Expand Up @@ -5537,6 +5543,7 @@
"eXWIo2": "Pre-filled value used if the user doesn't enter anything.",
"eXcejw": "In progress",
"eaEXYa": "All",
"eaI2mF": "Invalid JSON. Fix the errors before saving.",
"eagv8j": "Create logic app workspace",
"eb91v1": "Change connection",
"ec13/p": "Learn more",
Expand Down Expand Up @@ -5848,6 +5855,7 @@
"kSXjTx": "No description",
"kU4VfD": "Stateful",
"kVwJXt": "(UTC+04:00) Abu Dhabi, Muscat",
"kWpQvf": "Discard",
"kXn5e0": "Ask a question about this workflow or about Azure Logic Apps as a whole ...",
"kZCX7t": "Monday",
"kZk/Ed": "Alt/Option + select to download ''{displayName}''",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ const ContextSettings = () => {
checked={hostOptions.collapseGraphsByDefault}
onChange={(_, checked) => dispatch(setHostOptions({ collapseGraphsByDefault: !!checked }))}
/>
<Checkbox
label="Editable Code View"
checked={hostOptions.enableEditableCodeView}
onChange={(_, checked) => dispatch(setHostOptions({ enableEditableCodeView: !!checked }))}
/>
<Checkbox
label="Suppress default node click"
checked={suppressDefaultNodeSelect}
Expand Down
2 changes: 2 additions & 0 deletions apps/Standalone/src/designer/state/workflowLoadingSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface WorkflowLoadingState {
maxStateHistorySize?: number; // maximum number of states to save in history for undo/redo
collapseGraphsByDefault?: boolean; // collapse scope by default
enableMultiVariable?: boolean; // supports creating multiple variables in one action
enableEditableCodeView?: boolean; // allow editing an action's JSON inline from the node code view tab
};
showPerformanceDebug?: boolean;
isFirstDesignerV2Load?: boolean;
Expand Down Expand Up @@ -66,6 +67,7 @@ const initialState: WorkflowLoadingState = {
maxStateHistorySize: 20,
collapseGraphsByDefault: false,
enableMultiVariable: true,
enableEditableCodeView: true,
},
showPerformanceDebug: false,
isFirstDesignerV2Load: false,
Expand Down
99 changes: 99 additions & 0 deletions libs/designer-ui/src/lib/peek/__test__/editableCodeView.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from 'react';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { EditableCodeView, type EditableCodeViewProps } from '../editableCodeView';

// MonacoEditor is heavy and pulls in the real monaco runtime; swap it for a textarea that
// forwards edits through the same onContentChanged contract.
vi.mock('../../editor/monaco', () => ({
MonacoEditor: ({
value,
onContentChanged,
readOnly,
}: {
value?: string;
onContentChanged: (e: { value: string }) => void;
readOnly?: boolean;
}) => (
<textarea
data-testid="monaco-editor"
aria-label="editable-code-view"
readOnly={readOnly}
value={value ?? ''}
onChange={(e) => onContentChanged({ value: e.target.value })}
/>
),
}));

const labels = { save: 'Save', saving: 'Saving…', discard: 'Discard' };

const renderComponent = (overrides: Partial<EditableCodeViewProps> = {}) => {
const props: EditableCodeViewProps = {
value: '{}',
onChange: vi.fn(),
onSave: vi.fn(),
onDiscard: vi.fn(),
labels,
...overrides,
};
return { props, ...render(<EditableCodeView {...props} />) };
};

describe('EditableCodeView', () => {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
});

test('renders the editor with the supplied value', () => {
renderComponent({ value: '{"a":1}' });
expect(screen.getByTestId('monaco-editor')).toHaveValue('{"a":1}');
});

test('forwards editor edits through onChange', () => {
const { props } = renderComponent();
fireEvent.change(screen.getByTestId('monaco-editor'), { target: { value: '{"b":2}' } });
expect(props.onChange).toHaveBeenCalledWith('{"b":2}');
});

test('disables Save and Discard when not dirty', () => {
renderComponent({ isDirty: false });
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled();
});

test('enables Save and Discard when dirty', () => {
renderComponent({ isDirty: true });
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'Discard' })).toBeEnabled();
});

test('invokes onSave / onDiscard when the buttons are clicked', () => {
const { props } = renderComponent({ isDirty: true });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(props.onSave).toHaveBeenCalledTimes(1);
expect(props.onDiscard).toHaveBeenCalledTimes(1);
});

test('surfaces a validation error and keeps Save disabled even when dirty', () => {
renderComponent({ isDirty: true, errorMessage: 'Invalid JSON' });
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Invalid JSON');
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});

test('shows a saving spinner and disables both buttons while saving', () => {
renderComponent({ isDirty: true, isSaving: true });
expect(screen.getByText('Saving…')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled();
});

test('renders a read-only editor and disabled actions when readOnly', () => {
renderComponent({ isDirty: true, readOnly: true });
expect(screen.getByTestId('monaco-editor')).toHaveAttribute('readonly');
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled();
});
});
112 changes: 112 additions & 0 deletions libs/designer-ui/src/lib/peek/editableCodeView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { MonacoEditor } from '../editor/monaco';
import { EditorLanguage } from '@microsoft/logic-apps-shared';
import { Button, Spinner, Text, makeStyles, tokens } from '@fluentui/react-components';
import { usePeekStyles } from './styles';

const useEditableCodeViewStyles = makeStyles({
container: {
display: 'flex',
flexDirection: 'column',
height: '100%',
gap: '8px',
},
toolbar: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
spacer: {
flexGrow: 1,
},
error: {
color: tokens.colorPaletteRedForeground1,
},
editorWrapper: {
flexGrow: 1,
minHeight: 0,
},
});

export interface EditableCodeViewProps {
/** The current text shown in the editor (controlled). */
value: string;
/** Called on every edit with the new editor contents. */
onChange: (value: string) => void;
/** Called when the user clicks Save. */
onSave: () => void;
/** Called when the user clicks Discard. */
onDiscard: () => void;
/** Whether the editor contents differ from the last applied value. */
isDirty?: boolean;
/** Whether a save is currently in progress. */
isSaving?: boolean;
/** Validation/error message to surface above the editor. */
errorMessage?: string;
/** Whether editing is disabled (renders a read-only editor). */
readOnly?: boolean;
labels: {
save: string;
saving: string;
discard: string;
};
}

export function EditableCodeView({
value,
onChange,
onSave,
onDiscard,
isDirty = false,
isSaving = false,
errorMessage,
readOnly = false,
labels,
}: EditableCodeViewProps): JSX.Element {
const peekStyles = usePeekStyles();
const styles = useEditableCodeViewStyles();

const hasError = !!errorMessage;
const saveDisabled = readOnly || !isDirty || isSaving || hasError;
const discardDisabled = readOnly || !isDirty || isSaving;

return (
<div className={styles.container} data-automation-id="msla-editable-code-view">
<div className={styles.toolbar}>
{errorMessage ? (
<Text size={200} className={styles.error} role="alert" data-automation-id="msla-editable-code-view-error">
{errorMessage}
</Text>
) : null}
<div className={styles.spacer} />
{isSaving ? <Spinner size="tiny" label={labels.saving} labelPosition="after" /> : null}
<Button size="small" appearance="secondary" onClick={onDiscard} disabled={discardDisabled}>
{labels.discard}
</Button>
<Button
size="small"
appearance="primary"
onClick={onSave}
disabled={saveDisabled}
data-automation-id="msla-editable-code-view-save"
>
{labels.save}
</Button>
</div>
<div className={`msla-card-inner-body ${peekStyles.root} ${styles.editorWrapper}`}>
<MonacoEditor
className={'msla-monaco-peek'}
value={value}
onContentChanged={(e) => onChange(e.value ?? '')}
fontSize={13}
readOnly={readOnly}
folding={false}
lineNumbers={'on'}
language={EditorLanguage.json}
label="editable-code-view"
wordWrap={'on'}
monacoContainerStyle={{ height: '100%' }}
/>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions libs/designer-ui/src/lib/peek/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { MonacoEditor } from '../editor/monaco';
import { EditorLanguage } from '@microsoft/logic-apps-shared';
import { usePeekStyles } from './styles';

export * from './editableCodeView';

export interface PeekProps {
input: string;
}
Expand Down
Loading
Loading