diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts index e487ebdac08..2ce7d279f82 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts @@ -52,6 +52,7 @@ import { createUnitTest } from '../unitTest/codefulUnitTest/createUnitTest'; import { createHttpHeaders } from '@azure/core-rest-pipeline'; import { getBundleVersionNumber } from '../../../utils/bundleFeed'; import { saveUnitTestDefinition } from '../../../utils/unitTest/codelessUnitTest'; +import * as DraftManager from '../../../utils/codeless/draftManager'; export default class OpenDesignerForLocalProject extends OpenDesignerBase { private readonly workflowFilePath: string; @@ -203,6 +204,9 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { } }, 3000); + // Check for existing draft before sending init data + const draftResult = DraftManager.loadDraft(this.workflowFilePath); + this.sendMsgToWebview({ command: ExtensionCommand.initialize_frame, data: { @@ -222,6 +226,14 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { isUnitTest: this.isUnitTest, unitTestDefinition: this.unitTestDefinition, runId: this.runId, + draftInfo: draftResult.hasDraft + ? { + hasDraft: true, + draftWorkflow: draftResult.draftWorkflow, + draftConnections: draftResult.draftConnections, + draftParameters: draftResult.draftParameters, + } + : undefined, }, }); @@ -233,6 +245,46 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { // }); break; } + case ExtensionCommand.saveDraft: { + try { + DraftManager.saveDraft(this.workflowFilePath, { + definition: msg.definition, + connectionReferences: msg.connectionReferences, + parameters: msg.parameters, + }); + this.sendMsgToWebview({ + command: ExtensionCommand.draftSaveResult, + data: { + success: true, + timestamp: Date.now(), + }, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error saving draft'; + this.sendMsgToWebview({ + command: ExtensionCommand.draftSaveResult, + data: { + success: false, + error: errorMessage, + timestamp: Date.now(), + }, + }); + } + break; + } + case ExtensionCommand.discardDraft: { + DraftManager.discardDraft(this.workflowFilePath); + this.panelMetadata = await this._getDesignerPanelMetadata(this.migrationOptions); + this.sendMsgToWebview({ + command: ExtensionCommand.update_panel_metadata, + data: { + panelMetadata: this.panelMetadata, + connectionData: this.connectionData, + apiHubServiceDetails: this.apiHubServiceDetails, + }, + }); + break; + } case ExtensionCommand.save: { await callWithTelemetryAndErrorHandling('SaveWorkflowFromDesigner', async (activateContext: IActionContext) => { // TODO(aeldridge): Temporarily removed validation due to 500 responses from validatePartial endpoint. Re-add once fixed. @@ -247,6 +299,8 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { this.panelMetadata.azureDetails?.tenantId, this.panelMetadata.azureDetails?.workflowManagementBaseUrl ); + // Clean up draft files after successful publish + DraftManager.discardDraft(this.workflowFilePath); // const savedLocalSettingsValues = (await getLocalSettingsJson(activateContext, localSettingsPath, true)).Values || {}; // let savedWorkflow: any; diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/draftManager.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/draftManager.test.ts new file mode 100644 index 00000000000..fcdb5dbebe0 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/draftManager.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'path'; + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock('../../../../constants', () => ({ + draftWorkflowFileName: 'workflow.draft.json', + draftConnectionsFileName: 'connections.draft.json', + draftParametersFileName: 'parameters.draft.json', +})); + +import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs'; +import { + getDraftWorkflowPath, + getDraftConnectionsPath, + getDraftParametersPath, + hasDraft, + saveDraft, + loadDraft, + discardDraft, +} from '../draftManager'; + +const workflowFilePath = path.join('/project', 'myWorkflow', 'workflow.json'); +const workflowDir = path.dirname(workflowFilePath); + +describe('draftManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('path helpers', () => { + it('getDraftWorkflowPath returns workflow.draft.json in the same directory', () => { + expect(getDraftWorkflowPath(workflowFilePath)).toBe(path.join(workflowDir, 'workflow.draft.json')); + }); + + it('getDraftConnectionsPath returns connections.draft.json in the same directory', () => { + expect(getDraftConnectionsPath(workflowFilePath)).toBe(path.join(workflowDir, 'connections.draft.json')); + }); + + it('getDraftParametersPath returns parameters.draft.json in the same directory', () => { + expect(getDraftParametersPath(workflowFilePath)).toBe(path.join(workflowDir, 'parameters.draft.json')); + }); + }); + + describe('hasDraft', () => { + it('returns true when draft workflow file exists', () => { + vi.mocked(existsSync).mockReturnValue(true); + expect(hasDraft(workflowFilePath)).toBe(true); + expect(existsSync).toHaveBeenCalledWith(path.join(workflowDir, 'workflow.draft.json')); + }); + + it('returns false when draft workflow file does not exist', () => { + vi.mocked(existsSync).mockReturnValue(false); + expect(hasDraft(workflowFilePath)).toBe(false); + }); + }); + + describe('saveDraft', () => { + const definition = { triggers: {}, actions: { action1: { type: 'Http' } } }; + const connectionReferences = { conn1: { api: { id: '/providers/test' } } }; + const parameters = { param1: { type: 'String', value: 'hello' } }; + + it('creates directory and writes definition file', () => { + saveDraft(workflowFilePath, { definition }); + + expect(mkdirSync).toHaveBeenCalledWith(workflowDir, { recursive: true }); + expect(writeFileSync).toHaveBeenCalledWith( + path.join(workflowDir, 'workflow.draft.json'), + JSON.stringify(definition, null, 4), + 'utf8' + ); + }); + + it('writes connections file when connectionReferences provided', () => { + saveDraft(workflowFilePath, { definition, connectionReferences }); + + expect(writeFileSync).toHaveBeenCalledWith( + path.join(workflowDir, 'connections.draft.json'), + JSON.stringify(connectionReferences, null, 4), + 'utf8' + ); + }); + + it('writes parameters file when parameters provided', () => { + saveDraft(workflowFilePath, { definition, parameters }); + + expect(writeFileSync).toHaveBeenCalledWith( + path.join(workflowDir, 'parameters.draft.json'), + JSON.stringify(parameters, null, 4), + 'utf8' + ); + }); + + it('does not write connections file when connectionReferences is undefined', () => { + saveDraft(workflowFilePath, { definition }); + + const writeCallPaths = vi.mocked(writeFileSync).mock.calls.map((call) => call[0]); + expect(writeCallPaths).not.toContain(path.join(workflowDir, 'connections.draft.json')); + }); + + it('does not write parameters file when parameters is undefined', () => { + saveDraft(workflowFilePath, { definition }); + + const writeCallPaths = vi.mocked(writeFileSync).mock.calls.map((call) => call[0]); + expect(writeCallPaths).not.toContain(path.join(workflowDir, 'parameters.draft.json')); + }); + + it('writes all three files when all data provided', () => { + saveDraft(workflowFilePath, { definition, connectionReferences, parameters }); + + expect(writeFileSync).toHaveBeenCalledTimes(3); + }); + }); + + describe('loadDraft', () => { + const definition = { triggers: {}, actions: {} }; + const connections = { conn1: { api: { id: '/test' } } }; + const parameters = { param1: { type: 'String' } }; + + it('returns hasDraft false when no draft file exists', () => { + vi.mocked(existsSync).mockReturnValue(false); + + const result = loadDraft(workflowFilePath); + + expect(result).toEqual({ hasDraft: false }); + }); + + it('loads only workflow definition when only draft workflow exists', () => { + vi.mocked(existsSync).mockImplementation((p: any) => { + return p === path.join(workflowDir, 'workflow.draft.json'); + }); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(definition)); + + const result = loadDraft(workflowFilePath); + + expect(result.hasDraft).toBe(true); + expect(result.draftWorkflow).toEqual(definition); + expect(result.draftConnections).toBeUndefined(); + expect(result.draftParameters).toBeUndefined(); + }); + + it('loads all draft artifacts when all files exist', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockImplementation((p: any) => { + if (String(p).includes('workflow.draft.json')) { + return JSON.stringify(definition); + } + if (String(p).includes('connections.draft.json')) { + return JSON.stringify(connections); + } + if (String(p).includes('parameters.draft.json')) { + return JSON.stringify(parameters); + } + return '{}'; + }); + + const result = loadDraft(workflowFilePath); + + expect(result.hasDraft).toBe(true); + expect(result.draftWorkflow).toEqual(definition); + expect(result.draftConnections).toEqual(connections); + expect(result.draftParameters).toEqual(parameters); + }); + + it('loads workflow and connections without parameters', () => { + vi.mocked(existsSync).mockImplementation((p: any) => { + return !String(p).includes('parameters.draft.json'); + }); + vi.mocked(readFileSync).mockImplementation((p: any) => { + if (String(p).includes('workflow.draft.json')) { + return JSON.stringify(definition); + } + if (String(p).includes('connections.draft.json')) { + return JSON.stringify(connections); + } + return '{}'; + }); + + const result = loadDraft(workflowFilePath); + + expect(result.hasDraft).toBe(true); + expect(result.draftWorkflow).toEqual(definition); + expect(result.draftConnections).toEqual(connections); + expect(result.draftParameters).toBeUndefined(); + }); + }); + + describe('discardDraft', () => { + it('deletes all existing draft files', () => { + vi.mocked(existsSync).mockReturnValue(true); + + discardDraft(workflowFilePath); + + expect(unlinkSync).toHaveBeenCalledTimes(3); + expect(unlinkSync).toHaveBeenCalledWith(path.join(workflowDir, 'workflow.draft.json')); + expect(unlinkSync).toHaveBeenCalledWith(path.join(workflowDir, 'connections.draft.json')); + expect(unlinkSync).toHaveBeenCalledWith(path.join(workflowDir, 'parameters.draft.json')); + }); + + it('only deletes files that exist', () => { + vi.mocked(existsSync).mockImplementation((p: any) => { + return String(p).includes('workflow.draft.json'); + }); + + discardDraft(workflowFilePath); + + expect(unlinkSync).toHaveBeenCalledTimes(1); + expect(unlinkSync).toHaveBeenCalledWith(path.join(workflowDir, 'workflow.draft.json')); + }); + + it('does nothing when no draft files exist', () => { + vi.mocked(existsSync).mockReturnValue(false); + + discardDraft(workflowFilePath); + + expect(unlinkSync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/codeless/draftManager.ts b/apps/vs-code-designer/src/app/utils/codeless/draftManager.ts new file mode 100644 index 00000000000..e291b5e0ac9 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/draftManager.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { draftWorkflowFileName, draftConnectionsFileName, draftParametersFileName } from '../../../constants'; +import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs'; +import * as path from 'path'; + +export interface DraftData { + definition: any; + connectionReferences?: any; + parameters?: any; +} + +export interface LoadDraftResult { + hasDraft: boolean; + draftWorkflow?: any; + draftConnections?: any; + draftParameters?: any; +} + +export function getDraftWorkflowPath(workflowFilePath: string): string { + return path.join(path.dirname(workflowFilePath), draftWorkflowFileName); +} + +export function getDraftConnectionsPath(workflowFilePath: string): string { + return path.join(path.dirname(workflowFilePath), draftConnectionsFileName); +} + +export function getDraftParametersPath(workflowFilePath: string): string { + return path.join(path.dirname(workflowFilePath), draftParametersFileName); +} + +export function hasDraft(workflowFilePath: string): boolean { + return existsSync(getDraftWorkflowPath(workflowFilePath)); +} + +export function saveDraft(workflowFilePath: string, data: DraftData): void { + const dir = path.dirname(workflowFilePath); + mkdirSync(dir, { recursive: true }); + + writeFileSync(getDraftWorkflowPath(workflowFilePath), JSON.stringify(data.definition, null, 4), 'utf8'); + + if (data.connectionReferences) { + writeFileSync(getDraftConnectionsPath(workflowFilePath), JSON.stringify(data.connectionReferences, null, 4), 'utf8'); + } + + if (data.parameters) { + writeFileSync(getDraftParametersPath(workflowFilePath), JSON.stringify(data.parameters, null, 4), 'utf8'); + } +} + +export function loadDraft(workflowFilePath: string): LoadDraftResult { + const draftWorkflowPath = getDraftWorkflowPath(workflowFilePath); + + if (!existsSync(draftWorkflowPath)) { + return { hasDraft: false }; + } + + const result: LoadDraftResult = { + hasDraft: true, + draftWorkflow: JSON.parse(readFileSync(draftWorkflowPath, 'utf8')), + }; + + const draftConnectionsPath = getDraftConnectionsPath(workflowFilePath); + if (existsSync(draftConnectionsPath)) { + result.draftConnections = JSON.parse(readFileSync(draftConnectionsPath, 'utf8')); + } + + const draftParametersPath = getDraftParametersPath(workflowFilePath); + if (existsSync(draftParametersPath)) { + result.draftParameters = JSON.parse(readFileSync(draftParametersPath, 'utf8')); + } + + return result; +} + +export function discardDraft(workflowFilePath: string): void { + const filesToDelete = [ + getDraftWorkflowPath(workflowFilePath), + getDraftConnectionsPath(workflowFilePath), + getDraftParametersPath(workflowFilePath), + ]; + + for (const filePath of filesToDelete) { + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } +} diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index aa6349043d1..8ded39d4c61 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -18,6 +18,9 @@ export const launchFileName = 'launch.json'; export const settingsFileName = 'settings.json'; export const extensionsFileName = 'extensions.json'; export const workflowFileName = 'workflow.json'; +export const draftWorkflowFileName = 'workflow.draft.json'; +export const draftConnectionsFileName = 'connections.draft.json'; +export const draftParametersFileName = 'parameters.draft.json'; export const codefulWorkflowFileName = 'workflow.cs'; export const funcIgnoreFileName = '.funcignore'; export const unitTestsFileName = '.unit-test.json'; diff --git a/apps/vs-code-react/src/app/designer/DesignerCommandBar/__test__/getRelativeTimeString.test.ts b/apps/vs-code-react/src/app/designer/DesignerCommandBar/__test__/getRelativeTimeString.test.ts new file mode 100644 index 00000000000..877d92953be --- /dev/null +++ b/apps/vs-code-react/src/app/designer/DesignerCommandBar/__test__/getRelativeTimeString.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getRelativeTimeString } from '../utils'; + +const mockMessages = { + secondsAgo: 'Autosaved seconds ago', + minutesAgo: 'Autosaved minutes ago', + oneHourAgo: 'Autosaved 1 hour ago', + hoursAgo: vi.fn((values?: Record) => `Autosaved ${values?.count} hours ago`), +}; + +describe('getRelativeTimeString', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return seconds ago for less than 1 minute', () => { + const now = new Date('2025-01-15T10:00:30.000Z'); + vi.setSystemTime(now); + + const savedTime = new Date('2025-01-15T10:00:05.000Z'); // 25 seconds ago + expect(getRelativeTimeString(savedTime, mockMessages)).toBe('Autosaved seconds ago'); + }); + + it('should return seconds ago for exactly 0 seconds', () => { + const now = new Date('2025-01-15T10:00:00.000Z'); + vi.setSystemTime(now); + + expect(getRelativeTimeString(now, mockMessages)).toBe('Autosaved seconds ago'); + }); + + it('should return minutes ago for 1-59 minutes', () => { + const now = new Date('2025-01-15T10:05:00.000Z'); + vi.setSystemTime(now); + + const savedTime = new Date('2025-01-15T10:00:00.000Z'); // 5 minutes ago + expect(getRelativeTimeString(savedTime, mockMessages)).toBe('Autosaved minutes ago'); + }); + + it('should return minutes ago for exactly 1 minute', () => { + const now = new Date('2025-01-15T10:01:00.000Z'); + vi.setSystemTime(now); + + const savedTime = new Date('2025-01-15T10:00:00.000Z'); // 1 minute ago + expect(getRelativeTimeString(savedTime, mockMessages)).toBe('Autosaved minutes ago'); + }); + + it('should return one hour ago for exactly 1 hour', () => { + const now = new Date('2025-01-15T11:00:00.000Z'); + vi.setSystemTime(now); + + const savedTime = new Date('2025-01-15T10:00:00.000Z'); // 1 hour ago + expect(getRelativeTimeString(savedTime, mockMessages)).toBe('Autosaved 1 hour ago'); + }); + + it('should return hours ago with count for 2+ hours', () => { + const now = new Date('2025-01-15T13:00:00.000Z'); + vi.setSystemTime(now); + + const savedTime = new Date('2025-01-15T10:00:00.000Z'); // 3 hours ago + expect(getRelativeTimeString(savedTime, mockMessages)).toBe('Autosaved 3 hours ago'); + expect(mockMessages.hoursAgo).toHaveBeenCalledWith({ count: 3 }); + }); + + it('should return hours ago for large time differences', () => { + const now = new Date('2025-01-16T10:00:00.000Z'); + vi.setSystemTime(now); + + const savedTime = new Date('2025-01-15T10:00:00.000Z'); // 24 hours ago + expect(getRelativeTimeString(savedTime, mockMessages)).toBe('Autosaved 24 hours ago'); + expect(mockMessages.hoursAgo).toHaveBeenCalledWith({ count: 24 }); + }); + + it('should return minutes ago at 59 minutes boundary', () => { + const now = new Date('2025-01-15T10:59:59.000Z'); + vi.setSystemTime(now); + + const savedTime = new Date('2025-01-15T10:00:00.000Z'); // 59 min 59 sec ago + expect(getRelativeTimeString(savedTime, mockMessages)).toBe('Autosaved minutes ago'); + }); +}); diff --git a/apps/vs-code-react/src/app/designer/DesignerCommandBar/__test__/indexV2.test.tsx b/apps/vs-code-react/src/app/designer/DesignerCommandBar/__test__/indexV2.test.tsx new file mode 100644 index 00000000000..01adc2d119c --- /dev/null +++ b/apps/vs-code-react/src/app/designer/DesignerCommandBar/__test__/indexV2.test.tsx @@ -0,0 +1,388 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; + +// Use vi.hoisted to define mock variables that are used in vi.mock factories +const { mockPostMessage, mockDesignerIsDirty, mockChangeCount } = vi.hoisted(() => { + return { + mockPostMessage: vi.fn(), + mockDesignerIsDirty: { current: false }, + mockChangeCount: { current: 0 }, + }; +}); + +// Mock webviewCommunication to avoid acquireVsCodeApi global +vi.mock('../../../../webviewCommunication', async () => { + const React = await import('react'); + return { + VSCodeContext: React.createContext({ postMessage: mockPostMessage }), + }; +}); + +vi.mock('@microsoft/logic-apps-designer-v2', () => ({ + serializeWorkflow: vi.fn(), + store: { dispatch: vi.fn(), getState: vi.fn(() => ({ operations: { inputParameters: {} }, customCode: {} })) }, + serializeUnitTestDefinition: vi.fn(), + getNodeOutputOperations: vi.fn(), + useIsDesignerDirty: vi.fn(() => mockDesignerIsDirty.current), + validateParameter: vi.fn(() => []), + updateParameterValidation: vi.fn(), + openPanel: vi.fn((arg: any) => arg), + useAssertionsValidationErrors: vi.fn(() => ({})), + useWorkflowParameterValidationErrors: vi.fn(() => ({})), + useAllSettingsValidationErrors: vi.fn(() => ({})), + useAllConnectionErrors: vi.fn(() => ({})), + getCustomCodeFilesWithData: vi.fn(() => ({})), + resetDesignerDirtyState: vi.fn(), + resetDesignerView: vi.fn(), + collapsePanel: vi.fn(), + useChangeCount: vi.fn(() => mockChangeCount.current), +})); + +vi.mock('@microsoft/logic-apps-shared', () => ({ + isNullOrEmpty: vi.fn((val) => !val || Object.keys(val).length === 0), + useThrottledEffect: vi.fn(), +})); + +vi.mock('@microsoft/vscode-extension-logic-apps', () => ({ + ExtensionCommand: { + save: 'save', + saveUnitTest: 'saveUnitTest', + createUnitTest: 'createUnitTest', + createUnitTestFromRun: 'createUnitTestFromRun', + logTelemetry: 'logTelemetry', + fileABug: 'fileABug', + }, +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn((fn: any) => ({ + mutate: vi.fn(() => fn?.()), + isLoading: false, + })), +})); + +vi.mock('../styles', () => ({ + useCommandBarStyles: vi.fn(() => ({ + viewModeContainer: 'viewModeContainer', + viewButton: 'viewButton', + selectedButton: 'selectedButton', + })), +})); + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(() => ({})), +})); + +vi.mock('../../../../intl', () => ({ + useIntlMessages: vi.fn(() => ({ + WORKFLOW_TAB: 'Workflow', + CODE_TAB: 'Code', + RUN_HISTORY_TAB: 'Run History', + PUBLISH: 'Publish', + PUBLISHING: 'Publishing...', + SAVE: 'Save', + DISCARD: 'Discard', + DISCARD_SESSION_CHANGES: 'Discard session changes', + DISCARD_DRAFT: 'Discard draft', + SAVING_DRAFT: 'Saving...', + ERROR_AUTOSAVING_DRAFT: 'Error autosaving draft', + AUTOSAVED_SECONDS_AGO: 'Autosaved seconds ago', + AUTOSAVED_MINUTES_AGO: 'Autosaved minutes ago', + AUTOSAVED_ONE_HOUR_AGO: 'Autosaved 1 hour ago', + SWITCH_TO_PUBLISHED: 'Switch to published', + SWITCH_TO_DRAFT: 'Switch to draft', + MORE_ACTIONS: 'More actions', + PARAMETERS: 'Parameters', + CONNECTIONS: 'Connections', + ERRORS: 'Errors', + SAVE_UNIT_TEST: 'Save unit test', + CREATE_UNIT_TEST: 'Create unit test', + CREATE_UNIT_TEST_FROM_RUN: 'Create unit test from run', + UNIT_TEST_ASSERTIONS: 'Assertions', + FILE_BUG: 'File a bug', + })), + useIntlFormatters: vi.fn(() => ({ + DRAFT_AUTOSAVED_AT: vi.fn(({ time }: any) => `Draft autosaved at ${time}`), + AUTOSAVED_HOURS_AGO: vi.fn(({ count }: any) => `Autosaved ${count} hours ago`), + })), + designerMessages: {}, +})); + +// Import after mocks +import { DesignerCommandBar, type DesignerCommandBarProps } from '../indexV2'; + +const defaultProps: DesignerCommandBarProps = { + isDarkMode: false, + isUnitTest: false, + isLocal: true, + runId: '', + saveWorkflow: vi.fn().mockResolvedValue({}), + saveWorkflowFromCode: vi.fn().mockResolvedValue({}), + discard: vi.fn(), + isDesignerView: true, + isCodeView: false, + isMonitoringView: false, + switchToDesignerView: vi.fn(), + switchToCodeView: vi.fn(), + switchToMonitoringView: vi.fn(), + isDraftMode: true, + saveDraftWorkflow: vi.fn(), + discardDraft: vi.fn(), + switchWorkflowMode: vi.fn(), + lastDraftSaveTime: null, + draftSaveError: null, + isDraftSaving: false, + hasDraft: false, +}; + +const renderCommandBar = (props: Partial = {}) => { + return render( + + + + ); +}; + +describe('DesignerCommandBar (V2)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDesignerIsDirty.current = false; + mockChangeCount.current = 0; + }); + + describe('View mode tabs', () => { + it('should render Workflow, Code, and Run History tabs', () => { + renderCommandBar(); + expect(screen.getByText('Workflow')).toBeDefined(); + expect(screen.getByText('Code')).toBeDefined(); + expect(screen.getByText('Run History')).toBeDefined(); + }); + + it('should call switchToCodeView when Code tab is clicked', () => { + const switchToCodeView = vi.fn(); + renderCommandBar({ switchToCodeView }); + fireEvent.click(screen.getByText('Code')); + expect(switchToCodeView).toHaveBeenCalled(); + }); + + it('should call switchToMonitoringView when Run History tab is clicked', () => { + const switchToMonitoringView = vi.fn(); + renderCommandBar({ switchToMonitoringView }); + fireEvent.click(screen.getByText('Run History')); + expect(switchToMonitoringView).toHaveBeenCalled(); + }); + }); + + describe('Save/Publish button', () => { + it('should display "Publish" when in draft mode', () => { + mockDesignerIsDirty.current = true; + renderCommandBar({ isDraftMode: true, hasDraft: true }); + expect(screen.getByText('Publish')).toBeDefined(); + }); + + it('should be disabled when in monitoring view', () => { + renderCommandBar({ isMonitoringView: true }); + const publishBtn = screen.getByText('Publish').closest('button'); + expect(publishBtn?.disabled).toBe(true); + }); + + it('should be disabled when not dirty and no draft', () => { + mockDesignerIsDirty.current = false; + renderCommandBar({ isDraftMode: true, hasDraft: false }); + const publishBtn = screen.getByText('Publish').closest('button'); + expect(publishBtn?.disabled).toBe(true); + }); + + it('should be enabled when hasDraft even if not dirty', () => { + mockDesignerIsDirty.current = false; + renderCommandBar({ isDraftMode: true, hasDraft: true }); + const publishBtn = screen.getByText('Publish').closest('button'); + expect(publishBtn?.disabled).toBe(false); + }); + + it('should be disabled when not in draft mode', () => { + mockDesignerIsDirty.current = true; + renderCommandBar({ isDraftMode: false, hasDraft: true }); + const publishBtn = screen.getByText('Publish').closest('button'); + expect(publishBtn?.disabled).toBe(true); + }); + }); + + describe('Discard button', () => { + it('should render simple discard button when no draft exists', () => { + renderCommandBar({ hasDraft: false }); + // No dropdown menu items visible + expect(screen.queryByText('Discard session changes')).toBeNull(); + expect(screen.queryByText('Discard draft')).toBeNull(); + }); + + it('should render discard dropdown menu when draft exists', () => { + renderCommandBar({ hasDraft: true }); + // The discard button should be a menu trigger - click it to open + const discardBtn = screen.getByLabelText('Discard'); + fireEvent.click(discardBtn); + expect(screen.getByText('Discard session changes')).toBeDefined(); + expect(screen.getByText('Discard draft')).toBeDefined(); + }); + + it('should call discardDraft when "Discard draft" is clicked', () => { + const discardDraft = vi.fn(); + renderCommandBar({ hasDraft: true, discardDraft }); + fireEvent.click(screen.getByLabelText('Discard')); + fireEvent.click(screen.getByText('Discard draft')); + expect(discardDraft).toHaveBeenCalled(); + }); + }); + + describe('Draft save notification', () => { + it('should show "Saving..." when isDraftSaving', () => { + renderCommandBar({ isDraftMode: true, isDraftSaving: true, isDesignerView: true }); + expect(screen.getByText('Saving...')).toBeDefined(); + }); + + it('should show error badge when draftSaveError exists', () => { + renderCommandBar({ isDraftMode: true, draftSaveError: 'Network error', isDesignerView: true }); + expect(screen.getByText('Error autosaving draft')).toBeDefined(); + }); + + it('should not show notification when not in draft mode', () => { + renderCommandBar({ isDraftMode: false, lastDraftSaveTime: Date.now(), isDesignerView: true }); + expect(screen.queryByText('Saving...')).toBeNull(); + }); + }); + + describe('Overflow menu', () => { + it('should show "Switch to published" when hasDraft and isDraftMode', () => { + renderCommandBar({ hasDraft: true, isDraftMode: true }); + fireEvent.click(screen.getByLabelText('More actions')); + expect(screen.getByText('Switch to published')).toBeDefined(); + }); + + it('should show "Switch to draft" when hasDraft and not isDraftMode', () => { + renderCommandBar({ hasDraft: true, isDraftMode: false }); + fireEvent.click(screen.getByLabelText('More actions')); + expect(screen.getByText('Switch to draft')).toBeDefined(); + }); + + it('should not show switch options when no draft exists', () => { + renderCommandBar({ hasDraft: false, isDraftMode: true }); + fireEvent.click(screen.getByLabelText('More actions')); + expect(screen.queryByText('Switch to published')).toBeNull(); + expect(screen.queryByText('Switch to draft')).toBeNull(); + }); + + it('should call switchWorkflowMode(false) when "Switch to published" is clicked', () => { + const switchWorkflowMode = vi.fn(); + renderCommandBar({ hasDraft: true, isDraftMode: true, switchWorkflowMode }); + fireEvent.click(screen.getByLabelText('More actions')); + fireEvent.click(screen.getByText('Switch to published')); + expect(switchWorkflowMode).toHaveBeenCalledWith(false); + }); + + it('should call switchWorkflowMode(true) when "Switch to draft" is clicked', () => { + const switchWorkflowMode = vi.fn(); + renderCommandBar({ hasDraft: true, isDraftMode: false, switchWorkflowMode }); + fireEvent.click(screen.getByLabelText('More actions')); + fireEvent.click(screen.getByText('Switch to draft')); + expect(switchWorkflowMode).toHaveBeenCalledWith(true); + }); + + it('should show "File a bug" menu item', () => { + renderCommandBar(); + fireEvent.click(screen.getByLabelText('More actions')); + expect(screen.getByText('File a bug')).toBeDefined(); + }); + + it('should show "Create unit test from run" when isLocal', () => { + renderCommandBar({ isLocal: true }); + fireEvent.click(screen.getByLabelText('More actions')); + expect(screen.getByText('Create unit test from run')).toBeDefined(); + }); + + it('should show unit test items when isUnitTest', () => { + renderCommandBar({ isUnitTest: true }); + fireEvent.click(screen.getByLabelText('More actions')); + expect(screen.getByText('Save unit test')).toBeDefined(); + expect(screen.getByText('Create unit test')).toBeDefined(); + expect(screen.getByText('Assertions')).toBeDefined(); + }); + + it('should show panel items (Parameters, Connections, Errors)', () => { + renderCommandBar(); + fireEvent.click(screen.getByLabelText('More actions')); + expect(screen.getByText('Parameters')).toBeDefined(); + expect(screen.getByText('Connections')).toBeDefined(); + expect(screen.getByText('Errors')).toBeDefined(); + }); + }); + + describe('Save button click handlers', () => { + it('should trigger save when Publish is clicked in designer view', () => { + mockDesignerIsDirty.current = true; + renderCommandBar({ isDraftMode: true, hasDraft: true, isDesignerView: true }); + const publishBtn = screen.getByText('Publish').closest('button')!; + fireEvent.click(publishBtn); + // The mutation should have been called (useMutation mock calls the fn) + }); + + it('should trigger code save when Publish is clicked in code view', () => { + mockDesignerIsDirty.current = true; + renderCommandBar({ isDraftMode: true, hasDraft: true, isDesignerView: false, isCodeView: true }); + const publishBtn = screen.getByText('Publish').closest('button')!; + fireEvent.click(publishBtn); + // The code save mutation should have been called + }); + }); + + describe('Discard button in non-draft mode', () => { + it('should disable simple discard when not dirty', () => { + mockDesignerIsDirty.current = false; + renderCommandBar({ hasDraft: false }); + const discardBtn = screen.getByLabelText('Discard'); + expect(discardBtn.closest('button')?.disabled).toBe(true); + }); + + it('should disable simple discard in monitoring view', () => { + renderCommandBar({ hasDraft: false, isMonitoringView: true }); + const discardBtn = screen.getByLabelText('Discard'); + expect(discardBtn.closest('button')?.disabled).toBe(true); + }); + + it('should call discard when simple discard button is clicked', () => { + mockDesignerIsDirty.current = true; + const discard = vi.fn(); + renderCommandBar({ hasDraft: false, discard }); + fireEvent.click(screen.getByLabelText('Discard')); + expect(discard).toHaveBeenCalled(); + }); + }); + + describe('Draft save notification with saved time', () => { + it('should show autosaved time when lastDraftSaveTime is set', () => { + const recentTime = Date.now() - 5000; // 5 seconds ago + renderCommandBar({ isDraftMode: true, lastDraftSaveTime: recentTime, isDesignerView: true }); + expect(screen.getByText('Autosaved seconds ago')).toBeDefined(); + }); + + it('should show notification in code view too', () => { + renderCommandBar({ isDraftMode: true, isDraftSaving: true, isDesignerView: false, isCodeView: true }); + expect(screen.getByText('Saving...')).toBeDefined(); + }); + + it('should not show notification in monitoring view', () => { + renderCommandBar({ isDraftMode: true, lastDraftSaveTime: Date.now(), isMonitoringView: true, isDesignerView: false }); + expect(screen.queryByText('Autosaved seconds ago')).toBeNull(); + }); + }); + + describe('View mode tab active states', () => { + it('should call switchToDesignerView when Workflow tab is clicked', () => { + const switchToDesignerView = vi.fn(); + renderCommandBar({ switchToDesignerView, isDesignerView: false, isCodeView: true }); + fireEvent.click(screen.getByText('Workflow')); + expect(switchToDesignerView).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/vs-code-react/src/app/designer/DesignerCommandBar/indexV2.tsx b/apps/vs-code-react/src/app/designer/DesignerCommandBar/indexV2.tsx index 6598e226486..7241672db1c 100644 --- a/apps/vs-code-react/src/app/designer/DesignerCommandBar/indexV2.tsx +++ b/apps/vs-code-react/src/app/designer/DesignerCommandBar/indexV2.tsx @@ -17,12 +17,14 @@ import { type RootState, resetDesignerView, collapsePanel, + useChangeCount, } from '@microsoft/logic-apps-designer-v2'; -import { isNullOrEmpty, type Workflow } from '@microsoft/logic-apps-shared'; +import { isNullOrEmpty, useThrottledEffect, type Workflow } from '@microsoft/logic-apps-shared'; import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; -import { useContext, useMemo } from 'react'; +import { useContext, useEffect, useMemo, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { + Badge, Button, Card, Divider, @@ -36,6 +38,7 @@ import { Spinner, Toolbar, ToolbarButton, + Tooltip, } from '@fluentui/react-components'; import { SaveRegular, @@ -57,10 +60,17 @@ import { CheckmarkFilled, ArrowUndoFilled, ArrowUndoRegular, + ArrowSyncFilled, + CheckmarkCircleRegular, + DocumentOnePageAddFilled, + DocumentOnePageAddRegular, + DocumentOnePageColumnsFilled, + DocumentOnePageColumnsRegular, } from '@fluentui/react-icons'; import { useCommandBarStyles } from './styles'; +import { getRelativeTimeString } from './utils'; import { useSelector } from 'react-redux'; -import { useIntlMessages, designerMessages } from '../../../intl'; +import { useIntlMessages, useIntlFormatters, designerMessages } from '../../../intl'; // Designer icons const SaveIcon = bundleIcon(SaveFilled, SaveRegular); @@ -77,6 +87,10 @@ const MoreHorizontalIcon = bundleIcon(MoreHorizontalFilled, MoreHorizontalRegula // Unit test icons const AssertionsIcon = bundleIcon(CheckmarkFilled, CheckmarkRegular); +// Draft/publish icons +const DocumentOnePageAddIcon = bundleIcon(DocumentOnePageAddFilled, DocumentOnePageAddRegular); +const DocumentOnePageColumnsIcon = bundleIcon(DocumentOnePageColumnsFilled, DocumentOnePageColumnsRegular); + export interface DesignerCommandBarProps { isDarkMode: boolean; isUnitTest: boolean; @@ -91,6 +105,14 @@ export interface DesignerCommandBarProps { switchToDesignerView: () => void; switchToCodeView: () => void; switchToMonitoringView: () => void; + isDraftMode: boolean; + saveDraftWorkflow: (definition: any, parameters: any, connectionReferences: any) => void; + discardDraft: () => void; + switchWorkflowMode: (toDraftMode: boolean) => void; + lastDraftSaveTime: number | null; + draftSaveError: string | null; + isDraftSaving: boolean; + hasDraft: boolean; } export const DesignerCommandBar: React.FC = ({ @@ -107,15 +129,27 @@ export const DesignerCommandBar: React.FC = ({ switchToDesignerView, switchToCodeView, switchToMonitoringView, + isDraftMode, + saveDraftWorkflow, + discardDraft, + switchWorkflowMode, + lastDraftSaveTime, + draftSaveError, + isDraftSaving, + hasDraft, }) => { const vscode = useContext(VSCodeContext); const dispatch = DesignerStore.dispatch; const styles = useCommandBarStyles(); const intlText = useIntlMessages(designerMessages); + const formatters = useIntlFormatters(designerMessages); const designerIsDirty = useIsDesignerDirty(); + const [autoSaving, setAutoSaving] = useState(false); + const [autosaveError, setAutosaveError] = useState(); + const { isLoading: isSaving, mutate: saveWorkflowMutate } = useMutation(async () => { try { const designerState = DesignerStore.getState(); @@ -152,6 +186,48 @@ export const DesignerCommandBar: React.FC = ({ } }); + // Auto-save draft mutation + const { mutate: autoSaveMutate } = useMutation(async () => { + try { + setAutoSaving(true); + setAutosaveError(undefined); + const designerState = DesignerStore.getState(); + const serializedWorkflow = await serializeBJSWorkflow(designerState, { + skipValidation: true, + ignoreNonCriticalErrors: true, + }); + saveDraftWorkflow(serializedWorkflow.definition, serializedWorkflow.parameters, serializedWorkflow.connectionReferences); + } catch (error: any) { + console.error('Error auto-saving draft:', error); + setAutosaveError(error?.message ?? 'Unknown error during auto-save'); + } finally { + setAutoSaving(false); + } + }); + + // When any change is made, set needsSaved to true + const changeCount = useChangeCount(); + const [needsSaved, setNeedsSaved] = useState(false); + useEffect(() => { + if (changeCount === 0) { + return; + } + setNeedsSaved(true); + }, [changeCount]); + + // Auto-save every 5 seconds if needed + useThrottledEffect( + () => { + if (!needsSaved || !isDraftMode || isSaving || isMonitoringView) { + return; + } + setNeedsSaved(false); + autoSaveMutate(); + }, + [isDraftMode, isSaving, isMonitoringView, needsSaved, autoSaveMutate], + 5000 + ); + const { mutate: saveWorkflowFromCode, isLoading: isSavingFromCode } = useMutation(async () => { _saveWorkflowFromCode(() => dispatch(resetDesignerDirtyState(undefined))); }); @@ -233,8 +309,8 @@ export const DesignerCommandBar: React.FC = ({ ); const isSaveDisabled = useMemo( - () => isMonitoringView || isSaving || isSavingFromCode || haveErrors || !designerIsDirty, - [isMonitoringView, isSaving, isSavingFromCode, haveErrors, designerIsDirty] + () => isMonitoringView || isSaving || isSavingFromCode || haveErrors || (!designerIsDirty && !hasDraft) || !isDraftMode, + [isMonitoringView, isSaving, isSavingFromCode, haveErrors, designerIsDirty, hasDraft, isDraftMode] ); const ViewModeSelect = () => ( @@ -249,7 +325,7 @@ export const DesignerCommandBar: React.FC = ({ switchToDesignerView(); }} > - Workflow + {intlText.WORKFLOW_TAB} ); - const SaveButton = () => ( - { - if (isDesignerView) { - saveWorkflowMutate(); - } else { - saveWorkflowFromCode(); - } - }} - icon={isSaving ? : undefined} - > - {isSaving ? 'Saving' : 'Save'} - - ); + const SaveButton = () => { + const publishLoading = !autoSaving && isSaving; + return ( + { + if (isDesignerView) { + saveWorkflowMutate(); + } else { + saveWorkflowFromCode(); + } + }} + icon={publishLoading ? : undefined} + > + {publishLoading ? intlText.PUBLISHING : intlText.PUBLISH} + + ); + }; - const DiscardButton = () => ( - } - onClick={discard} - disabled={isSaving || isMonitoringView || !designerIsDirty} - /> - ); + const DiscardButton = () => { + if (hasDraft) { + return ( + + + } disabled={isSaving || isMonitoringView} /> + + + + + {intlText.DISCARD_SESSION_CHANGES} + + {intlText.DISCARD_DRAFT} + + + + ); + } + + return ( + } + onClick={discard} + disabled={isSaving || isMonitoringView || !designerIsDirty} + /> + ); + }; + + const DraftSaveNotification = () => { + const [, setTick] = useState(0); + + useEffect(() => { + if (isDraftMode && lastDraftSaveTime) { + const interval = setInterval(() => { + setTick((prev) => prev + 1); + }, 2000); + + return () => clearInterval(interval); + } + return undefined; + }, []); + + if (isDraftMode && (isDesignerView || isCodeView)) { + const style = { fontStyle: 'italic' as const, fontSize: '12px' }; + const iconStyle = { fontSize: '16px' }; + + if (isDraftSaving || autoSaving || needsSaved) { + return ( + }> + {intlText.SAVING_DRAFT} + + ); + } + + const savedTime = lastDraftSaveTime ? new Date(lastDraftSaveTime) : null; + + return savedTime ? ( + + }> + {getRelativeTimeString(savedTime, { + secondsAgo: intlText.AUTOSAVED_SECONDS_AGO, + minutesAgo: intlText.AUTOSAVED_MINUTES_AGO, + oneHourAgo: intlText.AUTOSAVED_ONE_HOUR_AGO, + hoursAgo: formatters.AUTOSAVED_HOURS_AGO, + })} + + + ) : draftSaveError || autosaveError ? ( + + }> + {intlText.ERROR_AUTOSAVING_DRAFT} + + + ) : null; + } + return null; + }; const UnitTestItems = () => ( <> @@ -356,10 +505,21 @@ export const DesignerCommandBar: React.FC = ({ const OverflowMenu = () => ( - } /> + } /> + {hasDraft && isDraftMode && ( + switchWorkflowMode(false)} icon={}> + {intlText.SWITCH_TO_PUBLISHED} + + )} + {hasDraft && !isDraftMode && ( + switchWorkflowMode(true)} icon={}> + {intlText.SWITCH_TO_DRAFT} + + )} + {hasDraft && } {isLocal && ( }> {intlText.CREATE_UNIT_TEST_FROM_RUN} @@ -395,6 +555,7 @@ export const DesignerCommandBar: React.FC = ({ >
+ diff --git a/apps/vs-code-react/src/app/designer/DesignerCommandBar/utils.ts b/apps/vs-code-react/src/app/designer/DesignerCommandBar/utils.ts new file mode 100644 index 00000000000..fa5b6130a9d --- /dev/null +++ b/apps/vs-code-react/src/app/designer/DesignerCommandBar/utils.ts @@ -0,0 +1,23 @@ +export const getRelativeTimeString = ( + savedTime: Date, + messages: { + secondsAgo: string; + minutesAgo: string; + oneHourAgo: string; + hoursAgo: (values?: Record) => string; + } +) => { + const now = new Date(); + const diffMs = now.getTime() - savedTime.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + + if (diffHours > 0) { + return diffHours === 1 ? messages.oneHourAgo : messages.hoursAgo({ count: diffHours }); + } + if (diffMinutes > 0) { + return messages.minutesAgo; + } + return messages.secondsAgo; +}; diff --git a/apps/vs-code-react/src/app/designer/__test__/appV2.test.tsx b/apps/vs-code-react/src/app/designer/__test__/appV2.test.tsx new file mode 100644 index 00000000000..84ce168a250 --- /dev/null +++ b/apps/vs-code-react/src/app/designer/__test__/appV2.test.tsx @@ -0,0 +1,529 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { designerSlice } from '../../../state/DesignerSlice'; + +// Use vi.hoisted to define mock variables that are used in vi.mock factories +const { mockPostMessage, mockConvertConnectionsDataToReferences } = vi.hoisted(() => { + return { + mockPostMessage: vi.fn(), + mockConvertConnectionsDataToReferences: vi.fn(() => ({ publishedConn: { api: { id: '/test' } } })), + }; +}); + +// Mock webviewCommunication to avoid acquireVsCodeApi global +vi.mock('../../../webviewCommunication', async () => { + const React = await import('react'); + return { + VSCodeContext: React.createContext({ postMessage: mockPostMessage }), + }; +}); + +// Mock DesignerCommandBar - capture props for assertions +let capturedCommandBarProps: any = null; +vi.mock('../DesignerCommandBar/indexV2', () => ({ + DesignerCommandBar: (props: any) => { + capturedCommandBarProps = props; + return
; + }, +})); + +vi.mock('../servicesHelper', () => ({ + getDesignerServices: vi.fn(() => ({ + connectionService: {}, + connectorService: {}, + operationManifestService: {}, + searchService: {}, + oAuthService: {}, + gatewayService: {}, + tenantService: {}, + workflowService: { getAgentUrl: vi.fn() }, + hostService: {}, + runService: { getRun: vi.fn().mockResolvedValue(null) }, + roleService: {}, + editorService: {}, + apimService: {}, + loggerService: {}, + connectionParameterEditorService: {}, + cognitiveServiceService: {}, + functionService: {}, + })), +})); + +vi.mock('../utilities/runInstance', () => ({ + getRunInstanceMocks: vi.fn(), +})); + +vi.mock('../utilities/workflow', () => ({ + convertConnectionsDataToReferences: mockConvertConnectionsDataToReferences, +})); + +vi.mock('../CodeViewEditor', () => ({ + default: React.forwardRef(() =>
), +})); + +vi.mock('@microsoft/logic-apps-designer-v2', () => ({ + DesignerProvider: ({ children }: any) =>
{children}
, + BJSWorkflowProvider: ({ children, workflow }: any) => ( +
+ {children} +
+ ), + Designer: () =>
, + FloatingRunButton: () =>
, + getTheme: vi.fn(() => 'light'), + useThemeObserver: vi.fn(), + useRun: vi.fn(() => ({ data: null, isError: false })), +})); + +vi.mock('@microsoft/logic-apps-shared', () => ({ + BundleVersionRequirements: { MULTI_VARIABLE: '1.0.0', NESTED_AGENT_LOOPS: '1.0.0' }, + guid: vi.fn(() => 'test-guid'), + isNullOrUndefined: vi.fn((val) => val === null || val === undefined), + isVersionSupported: vi.fn(() => false), + Theme: { Dark: 'dark', Light: 'light' }, +})); + +vi.mock('@microsoft/vscode-extension-logic-apps', () => ({ + ExtensionCommand: { + save: 'save', + saveDraft: 'saveDraft', + discardDraft: 'discardDraft', + createFileSystemConnection: 'createFileSystemConnection', + }, +})); + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: vi.fn(() => ({})), +})); + +vi.mock('@microsoft/designer-ui', () => ({ + XLargeText: ({ text }: any) =>
{text}
, +})); + +vi.mock('../appStyles', () => ({ + useAppStyles: vi.fn(() => ({ designerError: 'error-class' })), +})); + +vi.mock('../../intl', () => ({ + useIntlMessages: vi.fn(() => ({ SOMETHING_WENT_WRONG: 'Error' })), + commonMessages: {}, +})); + +// Import after mocks +import { DesignerApp } from '../appV2'; + +const mockWorkflowDefinition = { + triggers: { manual: { type: 'Request', kind: 'Http' } }, + actions: { action1: { type: 'Http' } }, +}; + +const mockDraftDefinition = { + triggers: { manual: { type: 'Request', kind: 'Http' } }, + actions: { draftAction: { type: 'Http' } }, +}; + +const createTestStore = (overrides: Partial> = {}) => { + return configureStore({ + reducer: { + designer: designerSlice.reducer, + }, + preloadedState: { + designer: { + ...designerSlice.getInitialState(), + panelMetaData: { + standardApp: { definition: mockWorkflowDefinition, kind: 'Stateful' }, + parametersData: { publishedParam: { type: 'String', value: 'pub' } }, + } as any, + connectionData: { publishedConn: { api: { id: '/test' } } } as any, + baseUrl: 'https://test.com', + apiVersion: '2018-11-01', + apiHubServiceDetails: { + apiVersion: '2018-07-01-preview', + baseUrl: '/url', + subscriptionId: 'sub', + resourceGroup: 'rg', + location: 'eastus', + tenantId: 'tenant', + httpClient: null as any, + }, + isLocal: true, + hostVersion: '1.0.0', + ...overrides, + }, + }, + }); +}; + +describe('DesignerApp (V2)', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedCommandBarProps = null; + }); + + it('should render designer when workflow definition exists', () => { + const store = createTestStore(); + render( + + + + ); + expect(screen.getByTestId('designer-provider')).toBeDefined(); + expect(screen.getByTestId('designer')).toBeDefined(); + expect(screen.getByTestId('command-bar')).toBeDefined(); + }); + + it('should render null when no panelMetaData', () => { + const store = createTestStore({ panelMetaData: null }); + const { container } = render( + + + + ); + // DesignerProvider still renders, but BJSWorkflowProvider should not + expect(screen.queryByTestId('bjs-workflow-provider')).toBeNull(); + }); + + it('should pass draft props to DesignerCommandBar', () => { + const store = createTestStore({ + isDraftMode: true, + hasDraft: true, + lastDraftSaveTime: 1234567890, + draftSaveError: null, + isDraftSaving: false, + }); + render( + + + + ); + + expect(capturedCommandBarProps).not.toBeNull(); + expect(capturedCommandBarProps.isDraftMode).toBe(true); + expect(capturedCommandBarProps.hasDraft).toBe(true); + expect(capturedCommandBarProps.lastDraftSaveTime).toBe(1234567890); + expect(capturedCommandBarProps.draftSaveError).toBeNull(); + expect(capturedCommandBarProps.isDraftSaving).toBe(false); + expect(typeof capturedCommandBarProps.saveDraftWorkflow).toBe('function'); + expect(typeof capturedCommandBarProps.discardDraft).toBe('function'); + expect(typeof capturedCommandBarProps.switchWorkflowMode).toBe('function'); + }); + + it('should use published connections when not in draft mode', () => { + const store = createTestStore({ + isDraftMode: false, + hasDraft: true, + draftConnections: { draftConn: { api: { id: '/draft' } } }, + }); + render( + + + + ); + + // When not in draft mode, convertConnectionsDataToReferences should be used + expect(mockConvertConnectionsDataToReferences).toHaveBeenCalled(); + }); + + it('should use draft connections when in draft mode with draft', () => { + mockConvertConnectionsDataToReferences.mockClear(); + const draftConns = { draftConn: { api: { id: '/draft' }, connection: { id: '/connections/draft' } } }; + const store = createTestStore({ + isDraftMode: true, + hasDraft: true, + draftConnections: draftConns, + }); + render( + + + + ); + + // The BJSWorkflowProvider should receive the draft connections + const provider = screen.getByTestId('bjs-workflow-provider'); + const workflowData = JSON.parse(provider.getAttribute('data-workflow') || '{}'); + expect(workflowData.connectionReferences).toEqual(draftConns); + }); + + it('should use draft parameters when in draft mode with draft', () => { + const draftParams = { draftParam: { type: 'String', value: 'draft-value' } }; + const store = createTestStore({ + isDraftMode: true, + hasDraft: true, + draftParameters: draftParams, + }); + render( + + + + ); + + const provider = screen.getByTestId('bjs-workflow-provider'); + const workflowData = JSON.parse(provider.getAttribute('data-workflow') || '{}'); + expect(workflowData.parameters).toEqual(draftParams); + }); + + it('should use published parameters when not in draft mode', () => { + const store = createTestStore({ + isDraftMode: false, + hasDraft: true, + draftParameters: { draftParam: { type: 'String', value: 'draft' } }, + }); + render( + + + + ); + + const provider = screen.getByTestId('bjs-workflow-provider'); + const workflowData = JSON.parse(provider.getAttribute('data-workflow') || '{}'); + expect(workflowData.parameters).toEqual({ publishedParam: { type: 'String', value: 'pub' } }); + }); + + it('should set readOnly when hasDraft and not isDraftMode', () => { + const store = createTestStore({ + isDraftMode: false, + hasDraft: true, + readOnly: false, + }); + render( + + + + ); + + // The published view should be read-only when a draft exists + // We verify via the DesignerProvider render + expect(screen.getByTestId('designer-provider')).toBeDefined(); + }); + + it('should dispatch saveDraft message when saveDraftWorkflow is called', () => { + const store = createTestStore({ isDraftMode: true, hasDraft: false }); + render( + + + + ); + + const definition = { triggers: {}, actions: {} }; + const params = { p1: { type: 'String' } }; + const connRefs = { c1: { api: { id: '/test' } } }; + capturedCommandBarProps.saveDraftWorkflow(definition, params, connRefs); + + expect(mockPostMessage).toHaveBeenCalledWith({ + command: 'saveDraft', + definition, + parameters: params, + connectionReferences: connRefs, + }); + }); + + it('should dispatch discardDraft message when discardDraft is called', () => { + const store = createTestStore({ isDraftMode: true, hasDraft: true }); + render( + + + + ); + + capturedCommandBarProps.discardDraft(); + + expect(mockPostMessage).toHaveBeenCalledWith({ command: 'discardDraft' }); + }); + + it('should dispatch setDraftSaving and update draft artifacts when saveDraftWorkflow is called', () => { + const store = createTestStore({ isDraftMode: true }); + render( + + + + ); + + const definition = { triggers: {}, actions: { a1: { type: 'Http' } } }; + const params = { p: { type: 'String' } }; + const conns = { c: { api: { id: '/test' } } }; + capturedCommandBarProps.saveDraftWorkflow(definition, params, conns); + + // Verify Redux state was updated + const state = store.getState().designer; + expect(state.isDraftSaving).toBe(true); + expect(state.draftWorkflow).toEqual(definition); + expect(state.draftConnections).toEqual(conns); + expect(state.draftParameters).toEqual(params); + }); + + it('should clear draft state after discardDraft', () => { + const store = createTestStore({ + isDraftMode: true, + hasDraft: true, + draftWorkflow: mockWorkflowDefinition, + }); + render( + + + + ); + + capturedCommandBarProps.discardDraft(); + + const state = store.getState().designer; + expect(state.hasDraft).toBe(false); + expect(state.draftWorkflow).toBeNull(); + expect(state.draftConnections).toBeNull(); + expect(state.draftParameters).toBeNull(); + }); + + it('should dispatch setDraftMode when switchWorkflowMode is called with false', () => { + const store = createTestStore({ + isDraftMode: true, + hasDraft: true, + draftWorkflow: mockDraftDefinition, + }); + render( + + + + ); + + capturedCommandBarProps.switchWorkflowMode(false); + + const state = store.getState().designer; + expect(state.isDraftMode).toBe(false); + }); + + it('should dispatch setDraftMode when switchWorkflowMode is called with true', () => { + const store = createTestStore({ + isDraftMode: false, + hasDraft: true, + draftWorkflow: mockDraftDefinition, + }); + render( + + + + ); + + capturedCommandBarProps.switchWorkflowMode(true); + + const state = store.getState().designer; + expect(state.isDraftMode).toBe(true); + }); + + it('should pass isMonitoringView to DesignerCommandBar', () => { + const store = createTestStore({ isMonitoringView: true }); + render( + + + + ); + + expect(capturedCommandBarProps.isMonitoringView).toBe(true); + }); + + it('should pass isUnitTest and isLocal to DesignerCommandBar', () => { + const store = createTestStore({ isUnitTest: true, isLocal: false }); + render( + + + + ); + + expect(capturedCommandBarProps.isUnitTest).toBe(true); + expect(capturedCommandBarProps.isLocal).toBe(false); + }); + + it('should render floating run button', () => { + const store = createTestStore(); + render( + + + + ); + expect(screen.getByTestId('floating-run-button')).toBeDefined(); + }); + + it('should use published connections when hasDraft but draftConnections is null', () => { + const store = createTestStore({ + isDraftMode: true, + hasDraft: true, + draftConnections: null, + }); + render( + + + + ); + + // Falls through to published since draftConnections is null + expect(mockConvertConnectionsDataToReferences).toHaveBeenCalled(); + }); + + it('should use published parameters when hasDraft but draftParameters is null', () => { + const store = createTestStore({ + isDraftMode: true, + hasDraft: true, + draftParameters: null, + }); + render( + + + + ); + + const provider = screen.getByTestId('bjs-workflow-provider'); + const workflowData = JSON.parse(provider.getAttribute('data-workflow') || '{}'); + expect(workflowData.parameters).toEqual({ publishedParam: { type: 'String', value: 'pub' } }); + }); + + it('should pass saveWorkflow and discard callbacks to command bar', () => { + const store = createTestStore(); + render( + + + + ); + + expect(typeof capturedCommandBarProps.saveWorkflow).toBe('function'); + expect(typeof capturedCommandBarProps.saveWorkflowFromCode).toBe('function'); + expect(typeof capturedCommandBarProps.discard).toBe('function'); + expect(typeof capturedCommandBarProps.switchToDesignerView).toBe('function'); + expect(typeof capturedCommandBarProps.switchToCodeView).toBe('function'); + expect(typeof capturedCommandBarProps.switchToMonitoringView).toBe('function'); + }); + + it('should post save command and clear draft when saveWorkflow is called', async () => { + const store = createTestStore({ + isDraftMode: true, + hasDraft: true, + draftWorkflow: mockWorkflowDefinition, + }); + render( + + + + ); + + const workflow = { + definition: { triggers: {}, actions: {} }, + parameters: { p: { type: 'String' } }, + connectionReferences: { c: { api: { id: '/test' } } }, + }; + const clearDirtyState = vi.fn(); + await capturedCommandBarProps.saveWorkflow(workflow, undefined, clearDirtyState); + + expect(mockPostMessage).toHaveBeenCalledWith({ + command: 'save', + definition: workflow.definition, + parameters: workflow.parameters, + connectionReferences: workflow.connectionReferences, + customCodeData: undefined, + }); + expect(clearDirtyState).toHaveBeenCalled(); + // Draft state should be cleared + const state = store.getState().designer; + expect(state.hasDraft).toBe(false); + expect(state.draftWorkflow).toBeNull(); + }); +}); diff --git a/apps/vs-code-react/src/app/designer/appV2.tsx b/apps/vs-code-react/src/app/designer/appV2.tsx index 586056d2b55..66bc9e597ce 100644 --- a/apps/vs-code-react/src/app/designer/appV2.tsx +++ b/apps/vs-code-react/src/app/designer/appV2.tsx @@ -1,4 +1,13 @@ -import { createFileSystemConnection, updateUnitTestDefinition } from '../../state/DesignerSlice'; +import { + createFileSystemConnection, + updateUnitTestDefinition, + clearDraftState, + setDraftMode, + setDraftSaving, + updateDraftWorkflow, + updateDraftConnections, + updateDraftParameters, +} from '../../state/DesignerSlice'; import type { AppDispatch, RootState } from '../../state/store'; import { VSCodeContext } from '../../webviewCommunication'; import { DesignerCommandBar } from './DesignerCommandBar/indexV2'; @@ -48,6 +57,14 @@ export const DesignerApp = () => { isUnitTest, unitTestDefinition, workflowRuntimeBaseUrl, + isDraftMode, + hasDraft, + draftWorkflow, + draftConnections, + draftParameters, + lastDraftSaveTime, + draftSaveError, + isDraftSaving, } = vscodeState; const [currentView, setCurrentView] = useState(_isMonitoringView ? DesignerViewType.Monitoring : DesignerViewType.Workflow); @@ -86,6 +103,55 @@ export const DesignerApp = () => { setDesignerID(guid()); }, []); + const saveDraftWorkflow = useCallback( + (definition: any, parameters: any, connectionReferences: any) => { + dispatch(setDraftSaving(true)); + // Keep all draft artifacts in Redux so switchWorkflowMode has fresh data + dispatch(updateDraftWorkflow(definition)); + dispatch(updateDraftConnections(connectionReferences)); + dispatch(updateDraftParameters(parameters)); + vscode.postMessage({ + command: ExtensionCommand.saveDraft, + definition, + parameters, + connectionReferences, + }); + }, + [vscode, dispatch] + ); + + const discardDraft = useCallback(() => { + vscode.postMessage({ + command: ExtensionCommand.discardDraft, + }); + dispatch(clearDraftState()); + setWorkflow(initialWorkflow); + setDesignerID(guid()); + }, [vscode, dispatch, initialWorkflow]); + + const switchWorkflowMode = useCallback( + (toDraftMode: boolean) => { + dispatch(setDraftMode(toDraftMode)); + if (toDraftMode) { + // Switching to draft: restore latest draft workflow from Redux + const currentDraftWorkflow = vscodeState.draftWorkflow; + if (vscodeState.hasDraft && currentDraftWorkflow && panelMetaData?.standardApp) { + const draftApp = { + ...panelMetaData.standardApp, + definition: currentDraftWorkflow, + } as StandardApp; + setWorkflow(draftApp); + } + } else { + // Switching to published: restore initial (published) workflow + setWorkflow(initialWorkflow); + } + setWorkflowDefinitionId(guid()); + setDesignerID(guid()); + }, + [dispatch, vscodeState, panelMetaData?.standardApp, initialWorkflow] + ); + const services = useMemo(() => { const fileSystemConnectionCreate = async ( connectionInfo: FileSystemConnectionInfo, @@ -134,8 +200,20 @@ export const DesignerApp = () => { ]); const connectionReferences: ConnectionReferences = useMemo(() => { + // Draft connections are already in ConnectionReferences format (from serialization). + // Published connections need conversion from ConnectionsData format. + if (isDraftMode && hasDraft && draftConnections) { + return draftConnections; + } return convertConnectionsDataToReferences(connectionData); - }, [connectionData]); + }, [connectionData, isDraftMode, hasDraft, draftConnections]); + + const parametersData = useMemo(() => { + if (isDraftMode && hasDraft && draftParameters) { + return draftParameters; + } + return panelMetaData?.parametersData; + }, [panelMetaData?.parametersData, isDraftMode, hasDraft, draftParameters]); const isMultiVariableSupportEnabled = useMemo( () => isVersionSupported(panelMetaData?.extensionBundleVersion ?? '', BundleVersionRequirements.MULTI_VARIABLE), @@ -170,9 +248,27 @@ export const DesignerApp = () => { }, [runInstance, isMonitoringView, isUnitTest, unitTestDefinition, services, dispatch]); useEffect(() => { - setWorkflow(panelMetaData?.standardApp); - setCustomCode(panelMetaData?.customCodeData); - }, [panelMetaData]); + if (!panelMetaData?.standardApp) { + return; + } + const publishedApp = panelMetaData.standardApp; + setInitialWorkflow(publishedApp); + setCustomCode(panelMetaData.customCodeData); + + // If a draft exists and we're in draft mode, show the draft workflow. + // Draft data arrives atomically with panelMetaData (via initializeDesigner), + // so hasDraft and draftWorkflow are already set when this effect runs. + if (hasDraft && draftWorkflow && isDraftMode) { + const draftApp = { + ...publishedApp, + definition: draftWorkflow, + } as StandardApp; + setWorkflow(draftApp); + } else { + setWorkflow(publishedApp); + } + setWorkflowDefinitionId(guid()); + }, [panelMetaData]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (runInstance) { @@ -206,6 +302,7 @@ export const DesignerApp = () => { setWorkflow(newWorkflow); setInitialWorkflow(newWorkflow); clearDirtyState?.(); + dispatch(clearDraftState()); return { definition, parameters, @@ -213,7 +310,7 @@ export const DesignerApp = () => { customCodeData, }; }, - [vscode, workflow] + [vscode, workflow, dispatch] ); const validateAndSaveCodeView = useCallback( @@ -317,7 +414,7 @@ export const DesignerApp = () => { isDarkMode: theme === Theme.Dark, isVSCode: true, isUnitTest, - readOnly: readOnly || isMonitoringView, + readOnly: readOnly || isMonitoringView || (hasDraft && !isDraftMode), isMonitoringView, services: services, hostOptions: { @@ -332,7 +429,7 @@ export const DesignerApp = () => { workflow={{ definition: workflow.definition, connectionReferences, - parameters: panelMetaData?.parametersData, + parameters: parametersData, kind: workflow.kind, }} workflowId={workflowDefinitionId} @@ -355,6 +452,14 @@ export const DesignerApp = () => { switchToDesignerView={switchToDesignerView} switchToCodeView={switchToCodeView} switchToMonitoringView={switchToMonitoringView} + isDraftMode={isDraftMode} + saveDraftWorkflow={saveDraftWorkflow} + discardDraft={discardDraft} + switchWorkflowMode={switchWorkflowMode} + lastDraftSaveTime={lastDraftSaveTime} + draftSaveError={draftSaveError} + isDraftSaving={isDraftSaving} + hasDraft={hasDraft} /> {!isCodeView && ( diff --git a/apps/vs-code-react/src/intl/__test__/messages.test.ts b/apps/vs-code-react/src/intl/__test__/messages.test.ts new file mode 100644 index 00000000000..03c49340531 --- /dev/null +++ b/apps/vs-code-react/src/intl/__test__/messages.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { + commonMessages, + unitTestMessages, + workspaceMessages, + exportMessages, + designerMessages, + overviewMessages, + chatMessages, +} from '../messages'; + +const messageGroups = { + commonMessages, + unitTestMessages, + workspaceMessages, + exportMessages, + designerMessages, + overviewMessages, + chatMessages, +}; + +describe('intl messages', () => { + for (const [groupName, messages] of Object.entries(messageGroups)) { + describe(groupName, () => { + it('should export a non-empty message group', () => { + expect(Object.keys(messages).length).toBeGreaterThan(0); + }); + + it('should have valid message descriptors with id and defaultMessage', () => { + for (const [key, descriptor] of Object.entries(messages)) { + expect(descriptor, `${groupName}.${key} missing id`).toHaveProperty('id'); + expect(descriptor, `${groupName}.${key} missing defaultMessage`).toHaveProperty('defaultMessage'); + expect(typeof (descriptor as any).id).toBe('string'); + expect(typeof (descriptor as any).defaultMessage).toBe('string'); + } + }); + }); + } +}); diff --git a/apps/vs-code-react/src/intl/messages.ts b/apps/vs-code-react/src/intl/messages.ts index e74ee2d9e2e..8e0796fb913 100644 --- a/apps/vs-code-react/src/intl/messages.ts +++ b/apps/vs-code-react/src/intl/messages.ts @@ -1044,6 +1044,91 @@ export const designerMessages = defineMessages({ id: 'JBRP7/', description: 'Chat button tooltip content', }, + WORKFLOW_TAB: { + defaultMessage: 'Workflow', + id: 'eBUll8', + description: 'Workflow view tab label', + }, + CODE_TAB: { + defaultMessage: 'Code', + id: '51R+Yi', + description: 'Code view tab label', + }, + RUN_HISTORY_TAB: { + defaultMessage: 'Run history', + id: 'VX+NSO', + description: 'Run history view tab label', + }, + PUBLISH: { + defaultMessage: 'Publish', + id: '/G2PKx', + description: 'Publish workflow button text', + }, + PUBLISHING: { + defaultMessage: 'Publishing...', + id: 't+O3cA', + description: 'Publishing workflow in progress text', + }, + DISCARD_SESSION_CHANGES: { + defaultMessage: 'Discard session changes', + id: 'G/cgpt', + description: 'Discard session changes menu item', + }, + DISCARD_DRAFT: { + defaultMessage: 'Discard draft (revert to published)', + id: '/Z4h+e', + description: 'Discard draft and revert to published menu item', + }, + SAVING_DRAFT: { + defaultMessage: 'Saving...', + id: 'WX0qjz', + description: 'Draft saving in progress text', + }, + ERROR_AUTOSAVING_DRAFT: { + defaultMessage: 'Error autosaving draft', + id: 'qPTkCH', + description: 'Draft auto-save error badge text', + }, + SWITCH_TO_PUBLISHED: { + defaultMessage: 'Switch to published version', + id: 'pEYjPM', + description: 'Switch to published version menu item', + }, + SWITCH_TO_DRAFT: { + defaultMessage: 'Switch to draft version', + id: 'WcfWf+', + description: 'Switch to draft version menu item', + }, + MORE_ACTIONS: { + defaultMessage: 'More', + id: 'RJt+Xc', + description: 'More actions overflow button aria label', + }, + AUTOSAVED_SECONDS_AGO: { + defaultMessage: 'Autosaved a few seconds ago', + id: 'eQH2yV', + description: 'Autosaved a few seconds ago text', + }, + AUTOSAVED_MINUTES_AGO: { + defaultMessage: 'Autosaved a few minutes ago', + id: 'PLsANU', + description: 'Autosaved a few minutes ago text', + }, + AUTOSAVED_ONE_HOUR_AGO: { + defaultMessage: 'Autosaved 1 hour ago', + id: 'usAOQT', + description: 'Autosaved one hour ago text', + }, + AUTOSAVED_HOURS_AGO: { + defaultMessage: 'Autosaved {count} hours ago', + id: 'PdiacD', + description: 'Autosaved multiple hours ago text', + }, + DRAFT_AUTOSAVED_AT: { + defaultMessage: 'Draft autosaved at: {time}', + id: '2EPQQD', + description: 'Draft autosaved tooltip showing exact time', + }, }); export const overviewMessages = defineMessages({ diff --git a/apps/vs-code-react/src/run-service/types.ts b/apps/vs-code-react/src/run-service/types.ts index 217877f4d53..43212d76f4f 100644 --- a/apps/vs-code-react/src/run-service/types.ts +++ b/apps/vs-code-react/src/run-service/types.ts @@ -295,6 +295,25 @@ export interface UpdatePanelMetadataMessage { }; } +export interface DraftLoadedMessage { + command: typeof ExtensionCommand.draftLoaded; + data: { + hasDraft: boolean; + draftWorkflow?: any; + draftConnections?: any; + draftParameters?: any; + }; +} + +export interface DraftSaveResultMessage { + command: typeof ExtensionCommand.draftSaveResult; + data: { + success: boolean; + timestamp: number; + error?: string; + }; +} + // Rest of Message Interfaces export interface InjectValuesMessage { command: typeof ExtensionCommand.initialize_frame; diff --git a/apps/vs-code-react/src/state/DesignerSlice.ts b/apps/vs-code-react/src/state/DesignerSlice.ts index 56f8886e6a2..4664d6b90b8 100644 --- a/apps/vs-code-react/src/state/DesignerSlice.ts +++ b/apps/vs-code-react/src/state/DesignerSlice.ts @@ -26,6 +26,14 @@ export interface DesignerState { hostVersion: string; isUnitTest: boolean; unitTestDefinition: UnitTestDefinition | null; + isDraftMode: boolean; + hasDraft: boolean; + draftWorkflow: any | null; + draftConnections: any | null; + draftParameters: any | null; + lastDraftSaveTime: number | null; + draftSaveError: string | null; + isDraftSaving: boolean; } const initialState: DesignerState = { @@ -57,6 +65,14 @@ const initialState: DesignerState = { hostVersion: '', isUnitTest: false, unitTestDefinition: null, + isDraftMode: true, + hasDraft: false, + draftWorkflow: null, + draftConnections: null, + draftParameters: null, + lastDraftSaveTime: null, + draftSaveError: null, + isDraftSaving: false, }; export const designerSlice = createSlice({ @@ -80,6 +96,7 @@ export const designerSlice = createSlice({ isUnitTest, unitTestDefinition, workflowRuntimeBaseUrl, + draftInfo, } = action.payload; state.panelMetaData = panelMetadata; @@ -96,6 +113,22 @@ export const designerSlice = createSlice({ state.hostVersion = hostVersion; state.isUnitTest = isUnitTest; state.unitTestDefinition = unitTestDefinition; + + // Process draft info if included in initialization + if (draftInfo?.hasDraft) { + state.hasDraft = true; + state.isDraftMode = true; + state.draftWorkflow = draftInfo.draftWorkflow ?? null; + state.draftConnections = draftInfo.draftConnections ?? null; + state.draftParameters = draftInfo.draftParameters ?? null; + } else { + // No draft exists - still in draft mode (editable) but no draft files + state.hasDraft = false; + state.isDraftMode = true; + state.draftWorkflow = null; + state.draftConnections = null; + state.draftParameters = null; + } }, updateRuntimeBaseUrl: (state, action: PayloadAction) => { state.workflowRuntimeBaseUrl = action.payload ?? ''; @@ -135,6 +168,62 @@ export const designerSlice = createSlice({ const { unitTestDefinition } = action.payload; state.unitTestDefinition = unitTestDefinition; }, + loadDraftState: ( + state, + action: PayloadAction<{ + hasDraft: boolean; + draftWorkflow?: any; + draftConnections?: any; + draftParameters?: any; + }> + ) => { + state.hasDraft = action.payload.hasDraft; + state.draftWorkflow = action.payload.draftWorkflow ?? null; + state.draftConnections = action.payload.draftConnections ?? null; + state.draftParameters = action.payload.draftParameters ?? null; + }, + updateDraftSaveResult: ( + state, + action: PayloadAction<{ + success: boolean; + timestamp: number; + error?: string; + }> + ) => { + state.isDraftSaving = false; + if (action.payload.success) { + state.lastDraftSaveTime = action.payload.timestamp; + state.draftSaveError = null; + state.hasDraft = true; + } else { + state.draftSaveError = action.payload.error ?? 'Unknown error'; + } + }, + setDraftSaving: (state, action: PayloadAction) => { + state.isDraftSaving = action.payload; + }, + updateDraftWorkflow: (state, action: PayloadAction) => { + state.draftWorkflow = action.payload; + }, + updateDraftConnections: (state, action: PayloadAction) => { + state.draftConnections = action.payload; + }, + updateDraftParameters: (state, action: PayloadAction) => { + state.draftParameters = action.payload; + }, + setDraftMode: (state, action: PayloadAction) => { + state.isDraftMode = action.payload; + }, + clearDraftState: (state) => { + state.hasDraft = false; + state.draftWorkflow = null; + state.draftConnections = null; + state.draftParameters = null; + state.lastDraftSaveTime = null; + state.draftSaveError = null; + state.isDraftSaving = false; + state.isDraftMode = true; + }, }, }); @@ -146,4 +235,12 @@ export const { updateFileSystemConnection, updatePanelMetadata, updateUnitTestDefinition, + loadDraftState, + updateDraftSaveResult, + setDraftSaving, + updateDraftWorkflow, + updateDraftConnections, + updateDraftParameters, + setDraftMode, + clearDraftState, } = designerSlice.actions; diff --git a/apps/vs-code-react/src/state/__test__/DesignerSlice.test.ts b/apps/vs-code-react/src/state/__test__/DesignerSlice.test.ts new file mode 100644 index 00000000000..5dc9a40a721 --- /dev/null +++ b/apps/vs-code-react/src/state/__test__/DesignerSlice.test.ts @@ -0,0 +1,406 @@ +import { describe, it, expect, vi } from 'vitest'; +import { designerSlice } from '../DesignerSlice'; +import type { DesignerState } from '../DesignerSlice'; +import { + initializeDesigner, + loadDraftState, + updateDraftSaveResult, + setDraftSaving, + updateDraftWorkflow, + updateDraftConnections, + updateDraftParameters, + setDraftMode, + clearDraftState, + updateRuntimeBaseUrl, + updateCallbackUrl, + updatePanelMetadata, + createFileSystemConnection, + updateFileSystemConnection, + updateUnitTestDefinition, +} from '../DesignerSlice'; + +const reducer = designerSlice.reducer; + +const getInitialState = (): DesignerState => ({ + panelMetaData: null, + baseUrl: '/url', + workflowRuntimeBaseUrl: '', + apiVersion: '2018-11-01', + connectionData: {}, + apiHubServiceDetails: { + apiVersion: '2018-07-01-preview', + baseUrl: '/url', + subscriptionId: 'subscriptionId', + resourceGroup: '', + location: '', + tenantId: '', + httpClient: null as any, + }, + readOnly: false, + isLocal: true, + isMonitoringView: false, + callbackInfo: { value: '', method: '' }, + runId: '', + fileSystemConnections: {}, + iaMapArtifacts: [], + oauthRedirectUrl: '', + hostVersion: '', + isUnitTest: false, + unitTestDefinition: null, + isDraftMode: true, + hasDraft: false, + draftWorkflow: null, + draftConnections: null, + draftParameters: null, + lastDraftSaveTime: null, + draftSaveError: null, + isDraftSaving: false, +}); + +const mockDraftWorkflow = { triggers: {}, actions: { action1: { type: 'Http' } } }; +const mockDraftConnections = { conn1: { api: { id: '/providers/test' }, connection: { id: '/connections/conn1' } } }; +const mockDraftParameters = { param1: { type: 'String', value: 'hello' } }; + +describe('DesignerSlice - core reducers', () => { + describe('updateRuntimeBaseUrl', () => { + it('should update workflowRuntimeBaseUrl', () => { + const result = reducer(getInitialState(), updateRuntimeBaseUrl('https://new-runtime.test.com')); + expect(result.workflowRuntimeBaseUrl).toBe('https://new-runtime.test.com'); + }); + + it('should set empty string when payload is undefined', () => { + const state = { ...getInitialState(), workflowRuntimeBaseUrl: 'https://old.com' }; + const result = reducer(state, updateRuntimeBaseUrl(undefined)); + expect(result.workflowRuntimeBaseUrl).toBe(''); + }); + }); + + describe('updateCallbackUrl', () => { + it('should update callbackInfo from payload', () => { + const callbackInfo = { value: 'https://callback.test.com/trigger', method: 'POST' }; + const result = reducer(getInitialState(), updateCallbackUrl({ callbackInfo })); + expect(result.callbackInfo).toEqual(callbackInfo); + }); + }); + + describe('updatePanelMetadata', () => { + it('should update panelMetaData, connectionData, and apiHubServiceDetails', () => { + const panelMetadata = { standardApp: { definition: { triggers: {} } } } as any; + const connectionData = { conn1: { api: { id: '/test' } } } as any; + const apiHubServiceDetails = { + apiVersion: '2020-06-01', + baseUrl: 'https://hub.test.com', + subscriptionId: 'sub-123', + resourceGroup: 'rg', + location: 'eastus', + tenantId: 'tenant-123', + httpClient: null as any, + }; + + const result = reducer(getInitialState(), updatePanelMetadata({ panelMetadata, connectionData, apiHubServiceDetails })); + + expect(result.panelMetaData).toEqual(panelMetadata); + expect(result.connectionData).toEqual(connectionData); + expect(result.apiHubServiceDetails).toEqual(apiHubServiceDetails); + }); + }); + + describe('createFileSystemConnection', () => { + it('should store resolve and reject callbacks for the connection', () => { + const resolve = vi.fn(); + const reject = vi.fn(); + const result = reducer(getInitialState(), createFileSystemConnection({ connectionName: 'myConn', resolve, reject })); + + expect(result.fileSystemConnections['myConn']).toBeDefined(); + expect(result.fileSystemConnections['myConn'].resolveConnection).toBe(resolve); + expect(result.fileSystemConnections['myConn'].rejectConnection).toBe(reject); + }); + }); + + describe('updateFileSystemConnection', () => { + it('should resolve the connection and remove it from state', () => { + const resolve = vi.fn(); + const reject = vi.fn(); + const state = { + ...getInitialState(), + fileSystemConnections: { myConn: { resolveConnection: resolve, rejectConnection: reject } }, + }; + const connection = { id: '/connections/myConn', name: 'myConn' }; + + const result = reducer(state, updateFileSystemConnection({ connectionName: 'myConn', connection } as any)); + + expect(resolve).toHaveBeenCalledWith(connection); + expect(reject).not.toHaveBeenCalled(); + expect(result.fileSystemConnections['myConn']).toBeUndefined(); + }); + + it('should reject the connection on error and remove it from state', () => { + const resolve = vi.fn(); + const reject = vi.fn(); + const state = { + ...getInitialState(), + fileSystemConnections: { myConn: { resolveConnection: resolve, rejectConnection: reject } }, + }; + + const result = reducer(state, updateFileSystemConnection({ connectionName: 'myConn', error: 'Connection failed' } as any)); + + expect(reject).toHaveBeenCalledWith({ message: 'Connection failed' }); + expect(resolve).not.toHaveBeenCalled(); + expect(result.fileSystemConnections['myConn']).toBeUndefined(); + }); + }); + + describe('updateUnitTestDefinition', () => { + it('should update unitTestDefinition', () => { + const unitTestDefinition = { + triggerMocks: { manual: { outputs: {} } }, + actionMocks: {}, + assertions: [{ name: 'test', description: '', expression: { operand1: '', operator: 'equals', operand2: '' } }], + } as any; + + const result = reducer(getInitialState(), updateUnitTestDefinition({ unitTestDefinition })); + + expect(result.unitTestDefinition).toEqual(unitTestDefinition); + }); + }); +}); + +describe('DesignerSlice - draft reducers', () => { + describe('initializeDesigner', () => { + const basePayload = { + panelMetadata: { standardApp: { definition: {} } }, + connectionData: {}, + baseUrl: 'https://test.com', + apiVersion: '2018-11-01', + apiHubServiceDetails: {}, + readOnly: false, + isLocal: true, + oauthRedirectUrl: '', + isMonitoringView: false, + runId: '', + hostVersion: '1.0.0', + isUnitTest: false, + unitTestDefinition: null, + workflowRuntimeBaseUrl: 'https://runtime.test.com', + }; + + it('should set hasDraft and draft artifacts when draftInfo is provided', () => { + const payload = { + ...basePayload, + draftInfo: { + hasDraft: true, + draftWorkflow: mockDraftWorkflow, + draftConnections: mockDraftConnections, + draftParameters: mockDraftParameters, + }, + }; + + const result = reducer(getInitialState(), initializeDesigner(payload)); + + expect(result.hasDraft).toBe(true); + expect(result.isDraftMode).toBe(true); + expect(result.draftWorkflow).toEqual(mockDraftWorkflow); + expect(result.draftConnections).toEqual(mockDraftConnections); + expect(result.draftParameters).toEqual(mockDraftParameters); + }); + + it('should set hasDraft false when draftInfo has no draft', () => { + const payload = { + ...basePayload, + draftInfo: { hasDraft: false }, + }; + + const result = reducer(getInitialState(), initializeDesigner(payload)); + + expect(result.hasDraft).toBe(false); + expect(result.isDraftMode).toBe(true); + expect(result.draftWorkflow).toBeNull(); + expect(result.draftConnections).toBeNull(); + expect(result.draftParameters).toBeNull(); + }); + + it('should reset draft state when draftInfo is undefined', () => { + const stateWithDraft = { + ...getInitialState(), + hasDraft: true, + draftWorkflow: mockDraftWorkflow, + draftConnections: mockDraftConnections, + }; + + const result = reducer(stateWithDraft, initializeDesigner(basePayload)); + + expect(result.hasDraft).toBe(false); + expect(result.isDraftMode).toBe(true); + expect(result.draftWorkflow).toBeNull(); + expect(result.draftConnections).toBeNull(); + expect(result.draftParameters).toBeNull(); + }); + + it('should handle draftInfo with partial artifacts', () => { + const payload = { + ...basePayload, + draftInfo: { + hasDraft: true, + draftWorkflow: mockDraftWorkflow, + // No connections or parameters + }, + }; + + const result = reducer(getInitialState(), initializeDesigner(payload)); + + expect(result.hasDraft).toBe(true); + expect(result.draftWorkflow).toEqual(mockDraftWorkflow); + expect(result.draftConnections).toBeNull(); + expect(result.draftParameters).toBeNull(); + }); + }); + + describe('loadDraftState', () => { + it('should set all draft fields from payload', () => { + const result = reducer( + getInitialState(), + loadDraftState({ + hasDraft: true, + draftWorkflow: mockDraftWorkflow, + draftConnections: mockDraftConnections, + draftParameters: mockDraftParameters, + }) + ); + + expect(result.hasDraft).toBe(true); + expect(result.draftWorkflow).toEqual(mockDraftWorkflow); + expect(result.draftConnections).toEqual(mockDraftConnections); + expect(result.draftParameters).toEqual(mockDraftParameters); + }); + + it('should set null for missing optional fields', () => { + const result = reducer(getInitialState(), loadDraftState({ hasDraft: true })); + + expect(result.hasDraft).toBe(true); + expect(result.draftWorkflow).toBeNull(); + expect(result.draftConnections).toBeNull(); + expect(result.draftParameters).toBeNull(); + }); + }); + + describe('updateDraftSaveResult', () => { + it('should update timestamp and clear error on success', () => { + const state = { ...getInitialState(), isDraftSaving: true, draftSaveError: 'old error' }; + + const result = reducer(state, updateDraftSaveResult({ success: true, timestamp: 1234567890 })); + + expect(result.isDraftSaving).toBe(false); + expect(result.lastDraftSaveTime).toBe(1234567890); + expect(result.draftSaveError).toBeNull(); + expect(result.hasDraft).toBe(true); + }); + + it('should set error on failure', () => { + const state = { ...getInitialState(), isDraftSaving: true }; + + const result = reducer(state, updateDraftSaveResult({ success: false, timestamp: 0, error: 'Save failed' })); + + expect(result.isDraftSaving).toBe(false); + expect(result.draftSaveError).toBe('Save failed'); + }); + + it('should set default error message when error is undefined on failure', () => { + const state = { ...getInitialState(), isDraftSaving: true }; + + const result = reducer(state, updateDraftSaveResult({ success: false, timestamp: 0 })); + + expect(result.draftSaveError).toBe('Unknown error'); + }); + }); + + describe('setDraftSaving', () => { + it('should set isDraftSaving to true', () => { + const result = reducer(getInitialState(), setDraftSaving(true)); + expect(result.isDraftSaving).toBe(true); + }); + + it('should set isDraftSaving to false', () => { + const state = { ...getInitialState(), isDraftSaving: true }; + const result = reducer(state, setDraftSaving(false)); + expect(result.isDraftSaving).toBe(false); + }); + }); + + describe('updateDraftWorkflow', () => { + it('should update draftWorkflow', () => { + const result = reducer(getInitialState(), updateDraftWorkflow(mockDraftWorkflow)); + expect(result.draftWorkflow).toEqual(mockDraftWorkflow); + }); + }); + + describe('updateDraftConnections', () => { + it('should update draftConnections', () => { + const result = reducer(getInitialState(), updateDraftConnections(mockDraftConnections)); + expect(result.draftConnections).toEqual(mockDraftConnections); + }); + }); + + describe('updateDraftParameters', () => { + it('should update draftParameters', () => { + const result = reducer(getInitialState(), updateDraftParameters(mockDraftParameters)); + expect(result.draftParameters).toEqual(mockDraftParameters); + }); + }); + + describe('setDraftMode', () => { + it('should set isDraftMode to true', () => { + const state = { ...getInitialState(), isDraftMode: false }; + const result = reducer(state, setDraftMode(true)); + expect(result.isDraftMode).toBe(true); + }); + + it('should set isDraftMode to false', () => { + const result = reducer(getInitialState(), setDraftMode(false)); + expect(result.isDraftMode).toBe(false); + }); + }); + + describe('clearDraftState', () => { + it('should reset all draft fields to defaults', () => { + const dirtyState: DesignerState = { + ...getInitialState(), + hasDraft: true, + isDraftMode: false, + draftWorkflow: mockDraftWorkflow, + draftConnections: mockDraftConnections, + draftParameters: mockDraftParameters, + lastDraftSaveTime: 9999999, + draftSaveError: 'some error', + isDraftSaving: true, + }; + + const result = reducer(dirtyState, clearDraftState()); + + expect(result.hasDraft).toBe(false); + expect(result.isDraftMode).toBe(true); + expect(result.draftWorkflow).toBeNull(); + expect(result.draftConnections).toBeNull(); + expect(result.draftParameters).toBeNull(); + expect(result.lastDraftSaveTime).toBeNull(); + expect(result.draftSaveError).toBeNull(); + expect(result.isDraftSaving).toBe(false); + }); + + it('should preserve non-draft state fields', () => { + const state: DesignerState = { + ...getInitialState(), + baseUrl: 'https://custom.com', + isLocal: false, + hasDraft: true, + draftWorkflow: mockDraftWorkflow, + }; + + const result = reducer(state, clearDraftState()); + + expect(result.baseUrl).toBe('https://custom.com'); + expect(result.isLocal).toBe(false); + expect(result.hasDraft).toBe(false); + expect(result.draftWorkflow).toBeNull(); + }); + }); +}); diff --git a/apps/vs-code-react/src/webviewCommunication.tsx b/apps/vs-code-react/src/webviewCommunication.tsx index 1b70e586977..1cbc7eb02bb 100644 --- a/apps/vs-code-react/src/webviewCommunication.tsx +++ b/apps/vs-code-react/src/webviewCommunication.tsx @@ -19,6 +19,8 @@ import type { GetTestFeatureEnablementStatus, GetAvailableCustomXsltPathsMessageV2, ResetDesignerDirtyStateMessage, + DraftLoadedMessage, + DraftSaveResultMessage, UpdateWorkspacePathMessage, UpdateWorkspacePackageMessage, ValidateWorkspacePathMessage, @@ -59,6 +61,8 @@ import { updateFileSystemConnection, updatePanelMetadata, updateRuntimeBaseUrl, + loadDraftState, + updateDraftSaveResult, } from './state/DesignerSlice'; import type { InitializePayload } from './state/WorkflowSlice'; import { @@ -97,7 +101,9 @@ type DesignerMessageType = | ReceiveCallbackMessage | ResetDesignerDirtyStateMessage | CompleteFileSystemConnectionMessage - | UpdatePanelMetadataMessage; + | UpdatePanelMetadataMessage + | DraftLoadedMessage + | DraftSaveResultMessage; type DataMapperMessageType = | FetchSchemaMessage | LoadDataMapMessage @@ -187,6 +193,14 @@ export const WebViewCommunication: React.FC<{ children: ReactNode }> = ({ childr designerDispatch(resetDesignerDirtyState(undefined)); break; } + case ExtensionCommand.draftLoaded: { + dispatch(loadDraftState(message.data)); + break; + } + case ExtensionCommand.draftSaveResult: { + dispatch(updateDraftSaveResult(message.data)); + break; + } case ExtensionCommand.getDesignerVersion: { dispatch(changeDesignerVersion(message.data)); break; diff --git a/apps/vs-code-react/vitest.config.ts b/apps/vs-code-react/vitest.config.ts index 6f284770835..bc89d461b1e 100644 --- a/apps/vs-code-react/vitest.config.ts +++ b/apps/vs-code-react/vitest.config.ts @@ -11,7 +11,7 @@ export default defineProject({ coverage: { enabled: true, provider: 'istanbul', - include: ['src/app/**/*', 'src/state/**/*'], + include: ['src/app/**/*', 'src/state/**/*', 'src/intl/**/*'], reporter: ['html', 'cobertura', 'lcov'], }, restoreMocks: true, diff --git a/libs/vscode-extension/src/lib/models/extensioncommand.ts b/libs/vscode-extension/src/lib/models/extensioncommand.ts index ad9b4e7648a..1ecd19facb6 100644 --- a/libs/vscode-extension/src/lib/models/extensioncommand.ts +++ b/libs/vscode-extension/src/lib/models/extensioncommand.ts @@ -54,6 +54,10 @@ export const ExtensionCommand = { isTestDisabledForOS: 'isTestDisabledForOS', fileABug: 'fileABug', resetDesignerDirtyState: 'resetDesignerDirtyState', + saveDraft: 'SaveDraft', + draftLoaded: 'DraftLoaded', + draftSaveResult: 'DraftSaveResult', + discardDraft: 'DiscardDraft', switchToDataMapperV2: 'switchToDataMapperV2', pickProcess: 'pickProcess', createWorkspace: 'createWorkspace',