From a4159d709bd3f3d413a93433960234971b3b8ccb Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Fri, 12 Sep 2025 17:55:02 -0500 Subject: [PATCH 01/94] intial changes to load empty dialog --- scripts/bundle-reactviews.js | 1 + src/constants/constants.ts | 1 + src/constants/locConstants.ts | 4 ++ src/controllers/mainController.ts | 24 +++++++ .../publishProjectWebViewController.ts | 67 +++++++++++++++++++ src/reactviews/pages/PublishProject/index.tsx | 15 +++++ .../pages/PublishProject/publishProject.tsx | 48 +++++++++++++ typings/vscode-mssql.d.ts | 9 +++ 8 files changed, 169 insertions(+) create mode 100644 src/publishProject/publishProjectWebViewController.ts create mode 100644 src/reactviews/pages/PublishProject/index.tsx create mode 100644 src/reactviews/pages/PublishProject/publishProject.tsx diff --git a/scripts/bundle-reactviews.js b/scripts/bundle-reactviews.js index 01c57a6c50..ac9f900cd4 100644 --- a/scripts/bundle-reactviews.js +++ b/scripts/bundle-reactviews.js @@ -26,6 +26,7 @@ const config = { 'userSurvey': 'src/reactviews/pages/UserSurvey/index.tsx', 'schemaDesigner': 'src/reactviews/pages/SchemaDesigner/index.tsx', 'schemaCompare': 'src/reactviews/pages/SchemaCompare/index.tsx', + 'publishDialog': 'src/reactviews/pages/PublishProject/index.tsx', }, bundle: true, outdir: 'dist/views', diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 0024fd15f7..34d3c188aa 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -50,6 +50,7 @@ export const cmdCommandPaletteQueryHistory = "mssql.commandPaletteQueryHistory"; export const cmdNewQuery = "mssql.newQuery"; export const cmdSchemaCompare = "mssql.schemaCompare"; export const cmdSchemaCompareOpenFromCommandPalette = "mssql.schemaCompareOpenFromCommandPalette"; +export const cmdPublishDatabaseProject = "mssql.publishDatabaseProject"; export const cmdManageConnectionProfiles = "mssql.manageProfiles"; export const cmdClearPooledConnections = "mssql.clearPooledConnections"; export const cmdRebuildIntelliSenseCache = "mssql.rebuildIntelliSenseCache"; diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index e750b4db06..0f53514eaf 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1177,6 +1177,10 @@ export class TableDesigner { public static AdvancedOptions = l10n.t("Advanced Options"); } +export class PublishProject { + public static Title = l10n.t("Publish Project"); +} + export class SchemaCompare { public static Title = l10n.t("Schema Compare"); public static Open = l10n.t("Open"); diff --git a/src/controllers/mainController.ts b/src/controllers/mainController.ts index cb3eff55dc..8417c2f82b 100644 --- a/src/controllers/mainController.ts +++ b/src/controllers/mainController.ts @@ -64,6 +64,7 @@ import store from "../queryResult/singletonStore"; import { SchemaCompareWebViewController } from "../schemaCompare/schemaCompareWebViewController"; import { SchemaCompare } from "../constants/locConstants"; import { SchemaDesignerWebviewManager } from "../schemaDesigner/schemaDesignerWebviewManager"; +import { PublishProjectWebViewController } from "../publishProject/publishProjectWebViewController"; import { ConnectionNode } from "../objectExplorer/nodes/connectionNode"; import { CopilotService } from "../services/copilotService"; import * as Prompts from "../copilot/prompts"; @@ -1555,6 +1556,12 @@ export default class MainController implements vscode.Disposable { ), ); + this._context.subscriptions.push( + vscode.commands.registerCommand(Constants.cmdPublishDatabaseProject, async () => { + await this.onPublishDatabaseProject(); + }), + ); + this._context.subscriptions.push( vscode.commands.registerCommand( Constants.cmdEditConnection, @@ -2610,6 +2617,23 @@ export default class MainController implements vscode.Disposable { schemaCompareWebView.revealToForeground(); } + /** + * Handler for the Schema Compare command. + * Accepts variable arguments, typically: + * - [sourceNode, targetNode, runComparison] when invoked from update Project SC or programmatically, + * - [sourceNode, undefined] when invoked from a project tree node/ server / database node, + * - [] when invoked from the command palette. + * This method normalizes the arguments and launches the Schema Compare UI. + */ + public async onPublishDatabaseProject(): Promise { + const schemaCompareWebView = new PublishProjectWebViewController( + this._context, + this._vscodeWrapper, + ); + + schemaCompareWebView.revealToForeground(); + } + /** * Check if the extension launched file exists. * This is to detect when we are running in a clean install scenario. diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts new file mode 100644 index 0000000000..2e0442e4b9 --- /dev/null +++ b/src/publishProject/publishProjectWebViewController.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from "vscode"; +import { ReactWebviewPanelController } from "../controllers/reactWebviewPanelController"; +import VscodeWrapper from "../controllers/vscodeWrapper"; +import { PublishProject as Loc } from "../constants/locConstants"; + +export class PublishProjectWebViewController extends ReactWebviewPanelController< + PublishDialogState, + PublishDialogReducers +> { + constructor(context: vscode.ExtensionContext, _vscodeWrapper: VscodeWrapper) { + super( + context, + _vscodeWrapper, + "publishDialog", + "Publish Database", + { + message: "Hello from Publish Dialog", + }, + { + title: Loc.Title, + viewColumn: vscode.ViewColumn.Active, + iconPath: { + dark: vscode.Uri.joinPath( + context.extensionUri, + "media", + "schemaCompare_dark.svg", + ), + light: vscode.Uri.joinPath( + context.extensionUri, + "media", + "schemaCompare_light.svg", + ), + }, + }, + ); + } + + protected get reducers(): Map< + keyof PublishDialogReducers, + (state: PublishDialogState, payload: any) => Promise + > { + const reducerMap = new Map< + keyof PublishDialogReducers, + (state: PublishDialogState, payload: any) => Promise + >(); + + reducerMap.set("test", async (state) => { + console.log("Test reducer called"); + return state; + }); + + return reducerMap; + } +} + +export interface PublishDialogState { + message: string; +} + +export type PublishDialogReducers = { + test: undefined; +}; diff --git a/src/reactviews/pages/PublishProject/index.tsx b/src/reactviews/pages/PublishProject/index.tsx new file mode 100644 index 0000000000..fa1e94dda9 --- /dev/null +++ b/src/reactviews/pages/PublishProject/index.tsx @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import ReactDOM from "react-dom/client"; +import "../../index.css"; +import { VscodeWebviewProvider } from "../../common/vscodeWebviewProvider"; +import { PublishProjectPage } from "./publishProject"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx new file mode 100644 index 0000000000..cf41311994 --- /dev/null +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { useContext, createContext } from "react"; +import { makeStyles } from "@fluentui/react-components"; + +// Define the context type +interface PublishProjectState { + message?: string; +} + +interface PublishProjectContextType { + state: PublishProjectState; +} + +// Create a typed context with a default value +const PublishProjectContext = createContext({ + state: { message: undefined }, +}); + +const useStyles = makeStyles({ + container: { + display: "flex", + flexDirection: "column", + height: "100vh", + overflow: "hidden", + padding: "20px", + }, + message: { + fontSize: "14px", + color: "#333", + }, +}); + +export let PublishProjectPage = () => { + const classes = useStyles(); + const context = useContext(PublishProjectContext); + return ( +
+

Publish Database Project

+
+ {context.state.message || "Publish Project Dialog - Ready to configure"} +
+
+ ); +}; diff --git a/typings/vscode-mssql.d.ts b/typings/vscode-mssql.d.ts index ce3f36baa8..1af5338a98 100644 --- a/typings/vscode-mssql.d.ts +++ b/typings/vscode-mssql.d.ts @@ -475,6 +475,15 @@ declare module "vscode-mssql" { cancel(operationId: string): Thenable; } + export interface IPublishDatabaseProjectService { + publishProject( + operationId: string, + targetServerName: string, + targetDatabaseName: string, + taskExecutionMode: TaskExecutionMode, + ): Thenable; + } + export interface IDacFxService { exportBacpac( databaseName: string, From cc2167f861664d017e615a404bd2b613adc40182 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Mon, 15 Sep 2025 17:01:47 -0500 Subject: [PATCH 02/94] UI intial changes --- src/constants/locConstants.ts | 20 +- src/controllers/mainController.ts | 12 +- .../publishProjectWebViewController.ts | 215 ++++++++++++++---- src/reactviews/common/locConstants.ts | 11 + .../components/PublishProfile.tsx | 100 ++++++++ src/reactviews/pages/PublishProject/index.tsx | 2 +- .../pages/PublishProject/publishProject.tsx | 153 ++++++++++--- .../publishProjectStateProvider.tsx | 76 +++++++ src/sharedInterfaces/publishDialog.ts | 72 ++++++ 9 files changed, 577 insertions(+), 84 deletions(-) create mode 100644 src/reactviews/pages/PublishProject/components/PublishProfile.tsx create mode 100644 src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx create mode 100644 src/sharedInterfaces/publishDialog.ts diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index 0f53514eaf..6595384f1c 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1177,9 +1177,23 @@ export class TableDesigner { public static AdvancedOptions = l10n.t("Advanced Options"); } -export class PublishProject { - public static Title = l10n.t("Publish Project"); -} +export const PublishProject = { + Title: "Publish Project", + ProfileLabel: "Publish Profile", + ProfilePlaceholder: "Select or enter a publish profile", + ServerLabel: "Server", + DatabaseLabel: "Database", + DatabaseRequiredMessage: "Database name is required", + PublishTargetLabel: "Publish Target", + PublishTargetExisting: "Existing SQL server", + PublishTargetContainer: "Local development container", + UpgradeExistingLabel: "Upgrade existing database", + BuildBeforePublishLabel: "Build project before publish", + Advanced: "Advanced...", + GenerateScript: "Generate Script", + Publish: "Publish", + Cancel: "Cancel", +}; export class SchemaCompare { public static Title = l10n.t("Schema Compare"); diff --git a/src/controllers/mainController.ts b/src/controllers/mainController.ts index 8417c2f82b..7652f313fc 100644 --- a/src/controllers/mainController.ts +++ b/src/controllers/mainController.ts @@ -1557,9 +1557,12 @@ export default class MainController implements vscode.Disposable { ); this._context.subscriptions.push( - vscode.commands.registerCommand(Constants.cmdPublishDatabaseProject, async () => { - await this.onPublishDatabaseProject(); - }), + vscode.commands.registerCommand( + Constants.cmdPublishDatabaseProject, + async (projectFilePath: string) => { + await this.onPublishDatabaseProject(projectFilePath); + }, + ), ); this._context.subscriptions.push( @@ -2625,10 +2628,11 @@ export default class MainController implements vscode.Disposable { * - [] when invoked from the command palette. * This method normalizes the arguments and launches the Schema Compare UI. */ - public async onPublishDatabaseProject(): Promise { + public async onPublishDatabaseProject(projectFilePath: string): Promise { const schemaCompareWebView = new PublishProjectWebViewController( this._context, this._vscodeWrapper, + projectFilePath, ); schemaCompareWebView.revealToForeground(); diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 2e0442e4b9..e4934f5f05 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -3,65 +3,194 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* Refactored to use the FormWebviewController pattern so the webview can render FormField + components exactly like the Connection dialog. */ + import * as vscode from "vscode"; -import { ReactWebviewPanelController } from "../controllers/reactWebviewPanelController"; +import { FormItemType } from "../sharedInterfaces/form"; +import { FormWebviewController } from "../forms/formWebviewController"; import VscodeWrapper from "../controllers/vscodeWrapper"; import { PublishProject as Loc } from "../constants/locConstants"; +import { + PublishDialogWebviewState, + PublishDialogReducers, + PublishDialogFormItemSpec, + IPublishForm, +} from "../sharedInterfaces/publishDialog"; -export class PublishProjectWebViewController extends ReactWebviewPanelController< - PublishDialogState, +export class PublishProjectWebViewController extends FormWebviewController< + IPublishForm, + PublishDialogWebviewState, + PublishDialogFormItemSpec, PublishDialogReducers > { - constructor(context: vscode.ExtensionContext, _vscodeWrapper: VscodeWrapper) { - super( - context, - _vscodeWrapper, - "publishDialog", - "Publish Database", - { - message: "Hello from Publish Dialog", + constructor( + context: vscode.ExtensionContext, + _vscodeWrapper: VscodeWrapper, + projectFilePath: string, + ) { + const initialFormState: IPublishForm = { + profileName: "", + serverName: "", + databaseName: getFileNameWithoutExt(projectFilePath), + publishTarget: "existingServer", + sqlCmdVariables: {}, + }; + + const formComponents: Partial> = { + profileName: { + propertyName: "profileName", + type: FormItemType.Input, + label: Loc.ProfileLabel, + required: false, + placeholder: Loc.ProfilePlaceholder ?? "", + }, + serverName: { + propertyName: "serverName", + type: FormItemType.Input, + label: Loc.ServerLabel, + required: false, + placeholder: Loc.ServerLabel ?? "", }, - { - title: Loc.Title, - viewColumn: vscode.ViewColumn.Active, - iconPath: { - dark: vscode.Uri.joinPath( - context.extensionUri, - "media", - "schemaCompare_dark.svg", - ), - light: vscode.Uri.joinPath( - context.extensionUri, - "media", - "schemaCompare_light.svg", - ), + databaseName: { + propertyName: "databaseName", + type: FormItemType.Input, + label: Loc.DatabaseLabel, + required: true, + placeholder: Loc.DatabaseLabel ?? "", + validate: (_state, value) => { + const isValid = (value as string).trim().length > 0; + return { + isValid, + validationMessage: isValid ? "" : (Loc.DatabaseRequiredMessage ?? ""), + }; }, }, - ); + publishTarget: { + propertyName: "publishTarget", + type: FormItemType.Dropdown, + label: Loc.PublishTargetLabel, + required: true, + options: [ + { + displayName: Loc.PublishTargetExisting ?? "Existing SQL server", + value: "existingServer", + }, + { + displayName: Loc.PublishTargetContainer ?? "Local development container", + value: "localContainer", + }, + ], + }, + }; + + const initialState: PublishDialogWebviewState = { + formState: initialFormState, + formComponents, + projectFilePath, + inProgress: false, + lastPublishResult: undefined, + message: undefined, + }; + + super(context, _vscodeWrapper, "publishDialog", "publishDialog", initialState, { + title: Loc.Title, + viewColumn: vscode.ViewColumn.Active, + iconPath: { + dark: vscode.Uri.joinPath(context.extensionUri, "media", "schemaCompare_dark.svg"), + light: vscode.Uri.joinPath( + context.extensionUri, + "media", + "schemaCompare_light.svg", + ), + }, + }); } - protected get reducers(): Map< - keyof PublishDialogReducers, - (state: PublishDialogState, payload: any) => Promise - > { - const reducerMap = new Map< - keyof PublishDialogReducers, - (state: PublishDialogState, payload: any) => Promise - >(); - - reducerMap.set("test", async (state) => { - console.log("Test reducer called"); + protected get reducers() { + const reducerMap = new Map(); + + reducerMap.set( + "setPublishValues", + async (state: PublishDialogWebviewState, payload: any) => { + if (payload) { + state.formState = { ...state.formState, ...payload }; + if (payload.projectFilePath) { + state.projectFilePath = payload.projectFilePath; + } + } + this.updateState(state); + return state; + }, + ); + + reducerMap.set("publishNow", async (state: PublishDialogWebviewState, _payload: any) => { + // Placeholder publish action; replace with deploy logic. + state.inProgress = false; + state.message = { type: "info", text: "Publish action placeholder." }; + this.updateState(state); + return state; + }); + + reducerMap.set("generatePublishScript", async (state: PublishDialogWebviewState) => { + state.message = { type: "info", text: "Generate script placeholder." }; + this.updateState(state); + return state; + }); + + reducerMap.set("openPublishAdvanced", async (state: PublishDialogWebviewState) => { + state.message = { type: "info", text: "Advanced settings placeholder." }; + this.updateState(state); + return state; + }); + + reducerMap.set("cancelPublish", async (state: PublishDialogWebviewState) => { + state.inProgress = false; + state.message = { type: "info", text: "Publish canceled." }; + this.updateState(state); return state; }); + reducerMap.set("selectPublishProfile", async (state: PublishDialogWebviewState) => { + state.message = { type: "info", text: "Select profile placeholder." }; + this.updateState(state); + return state; + }); + + reducerMap.set( + "savePublishProfile", + async (state: PublishDialogWebviewState, payload: any) => { + if (payload?.profileName) { + state.formState.profileName = payload.profileName; + } + state.message = { type: "info", text: "Save profile placeholder." }; + this.updateState(state); + return state; + }, + ); + return reducerMap; } -} -export interface PublishDialogState { - message: string; + protected getActiveFormComponents(_state: PublishDialogWebviewState) { + return [ + "publishTarget", + "profileName", + "serverName", + "databaseName", + ] as (keyof IPublishForm)[]; + } + + public async updateItemVisibility(): Promise { + return; + } } -export type PublishDialogReducers = { - test: undefined; -}; +function getFileNameWithoutExt(filePath: string): string { + if (!filePath) { + return ""; + } + const parts = filePath.replace(/\\/g, "/").split("/"); + const last = parts[parts.length - 1]; + return last.replace(/\.[^/.]+$/, ""); +} diff --git a/src/reactviews/common/locConstants.ts b/src/reactviews/common/locConstants.ts index f80fcf81bd..76512d772b 100644 --- a/src/reactviews/common/locConstants.ts +++ b/src/reactviews/common/locConstants.ts @@ -878,6 +878,17 @@ export class LocConstants { }; } + public get publishProject() { + return { + SelectProfile: l10n.t("Select Profile"), + SaveAs: l10n.t("Save As..."), + advanced: l10n.t("Advanced"), + generateScript: l10n.t("Generate Script"), + publish: l10n.t("Publish"), + cancel: l10n.t("Cancel"), + }; + } + public get connectionGroups() { return { createNew: l10n.t("Create New Connection Group"), diff --git a/src/reactviews/pages/PublishProject/components/PublishProfile.tsx b/src/reactviews/pages/PublishProject/components/PublishProfile.tsx new file mode 100644 index 0000000000..e30983665e --- /dev/null +++ b/src/reactviews/pages/PublishProject/components/PublishProfile.tsx @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Button, makeStyles } from "@fluentui/react-components"; +import { useContext } from "react"; +import { FormField } from "../../../common/forms/form.component"; +import { LocConstants } from "../../../common/locConstants"; +import { + IPublishForm, + PublishDialogFormItemSpec, + PublishDialogWebviewState, +} from "../../../../sharedInterfaces/publishDialog"; +import { PublishProjectContext } from "../publishProjectStateProvider"; +import { FormContextProps } from "../../../../sharedInterfaces/form"; + +/** + * Extended context type including the extra publish profile actions we expose. + */ +type PublishFormContext = FormContextProps< + IPublishForm, + PublishDialogWebviewState, + PublishDialogFormItemSpec +> & { + selectPublishProfile?: () => void; + savePublishProfile?: (profileName: string) => void; +}; + +const useStyles = makeStyles({ + root: { + display: "flex", + flexDirection: "row", + alignItems: "flex-end", + gap: "8px", + maxWidth: "640px", + width: "100%", + }, + buttons: { + display: "flex", + flexDirection: "row", + gap: "4px", + paddingBottom: "4px", + }, + fieldContainer: { + flexGrow: 1, + minWidth: 0, + }, +}); + +/** + * Custom field wrapper for Publish Profile. + * Renders the generic FormField for the text input and adds the action buttons alongside it. + */ +export default function PublishProfileField(props: { idx: number }) { + const { idx } = props; + const classes = useStyles(); + const loc = LocConstants.getInstance().publishProject; + const context = useContext(PublishProjectContext) as PublishFormContext | undefined; + + if (!context || !context.state) { + return null; + } + + const component = context.state.formComponents.profileName as PublishDialogFormItemSpec; + + return ( +
+
+ + context={context} + component={component} + idx={idx} + props={{ orientation: "horizontal" }} + /> +
+
+ + +
+
+ ); +} diff --git a/src/reactviews/pages/PublishProject/index.tsx b/src/reactviews/pages/PublishProject/index.tsx index fa1e94dda9..6fcab406d6 100644 --- a/src/reactviews/pages/PublishProject/index.tsx +++ b/src/reactviews/pages/PublishProject/index.tsx @@ -6,7 +6,7 @@ import ReactDOM from "react-dom/client"; import "../../index.css"; import { VscodeWebviewProvider } from "../../common/vscodeWebviewProvider"; -import { PublishProjectPage } from "./publishProject"; +import PublishProjectPage from "./publishProject"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index cf41311994..c3f6f7e1ae 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -3,46 +3,133 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { useContext, createContext } from "react"; -import { makeStyles } from "@fluentui/react-components"; - -// Define the context type -interface PublishProjectState { - message?: string; -} - -interface PublishProjectContextType { - state: PublishProjectState; -} - -// Create a typed context with a default value -const PublishProjectContext = createContext({ - state: { message: undefined }, -}); +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { useContext } from "react"; +import { Button, makeStyles } from "@fluentui/react-components"; +import { FormField, useFormStyles } from "../../common/forms/form.component"; +import { PublishProjectStateProvider, PublishProjectContext } from "./publishProjectStateProvider"; +import { LocConstants } from "../../common/locConstants"; +import { + IPublishForm, + PublishDialogFormItemSpec, + PublishDialogWebviewState, +} from "../../../sharedInterfaces/publishDialog"; +import { FormContextProps } from "../../../sharedInterfaces/form"; +import PublishProfileField from "./components/PublishProfile"; const useStyles = makeStyles({ - container: { + root: { padding: "12px" }, + footer: { + marginTop: "8px", display: "flex", - flexDirection: "column", - height: "100vh", - overflow: "hidden", - padding: "20px", - }, - message: { - fontSize: "14px", - color: "#333", + justifyContent: "flex-end", + gap: "12px", + alignItems: "center", + maxWidth: "640px", + width: "100%", + paddingTop: "12px", + borderTop: "1px solid transparent", }, }); -export let PublishProjectPage = () => { +type PublishFormContext = FormContextProps< + IPublishForm, + PublishDialogWebviewState, + PublishDialogFormItemSpec +> & { + publishNow: () => void; + generatePublishScript: () => void; + openPublishAdvanced: () => void; + cancelPublish: () => void; + selectPublishProfile: () => void; + savePublishProfile: (profileName: string) => void; +}; + +function PublishProjectInner() { const classes = useStyles(); - const context = useContext(PublishProjectContext); + const formStyles = useFormStyles(); + const loc = LocConstants.getInstance().publishProject; + const context = useContext(PublishProjectContext) as PublishFormContext | undefined; + + if (!context || !context.state) { + return
Loading...
; + } + + const state = context.state; + return ( -
-

Publish Database Project

-
- {context.state.message || "Publish Project Dialog - Ready to configure"} +
e.preventDefault()}> +
+
+ + context={context} + component={state.formComponents.publishTarget as PublishDialogFormItemSpec} + idx={0} + props={{ orientation: "horizontal" }} + /> + + + + + context={context} + component={state.formComponents.serverName as PublishDialogFormItemSpec} + idx={2} + props={{ orientation: "horizontal" }} + /> + + context={context} + component={state.formComponents.databaseName as PublishDialogFormItemSpec} + idx={3} + props={{ orientation: "horizontal" }} + /> + +
+ +
+ +
+ + + +
+
-
+ ); -}; +} + +export default function PublishProjectPageWrapper() { + return ( + + + + ); +} diff --git a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx new file mode 100644 index 0000000000..cf518fbb35 --- /dev/null +++ b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContext } from "react"; +import { useVscodeWebview } from "../../common/vscodeWebviewProvider"; +import { + PublishDialogWebviewState, + PublishDialogReducers, +} from "../../../sharedInterfaces/publishDialog"; + +interface PublishProjectContextValue { + state?: PublishDialogWebviewState; + formAction: (event: any) => void; + publishNow: (payload?: any) => void; + generatePublishScript: () => void; + openPublishAdvanced: () => void; + cancelPublish: () => void; + selectPublishProfile: () => void; + savePublishProfile: (profileName: string) => void; + setPublishValues: ( + values: Partial & { projectFilePath?: string }, + ) => void; + extensionRpc?: any; +} + +const PublishProjectContext = createContext(undefined); + +export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const webviewContext = useVscodeWebview(); + const state = webviewContext?.state; + + const formAction = (event: any) => webviewContext?.extensionRpc.action("formAction", { event }); + + const publishNow = (payload?: any) => + webviewContext?.extensionRpc.action("publishNow", payload); + + const generatePublishScript = () => + webviewContext?.extensionRpc.action("generatePublishScript"); + + const openPublishAdvanced = () => webviewContext?.extensionRpc.action("openPublishAdvanced"); + + const cancelPublish = () => webviewContext?.extensionRpc.action("cancelPublish"); + + const selectPublishProfile = () => webviewContext?.extensionRpc.action("selectPublishProfile"); + + const savePublishProfile = (profileName: string) => + webviewContext?.extensionRpc.action("savePublishProfile", { profileName }); + + const setPublishValues = ( + values: Partial & { projectFilePath?: string }, + ) => webviewContext?.extensionRpc.action("setPublishValues", values); + + return ( + + {children} + + ); +}; + +export { PublishProjectContext }; diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts new file mode 100644 index 0000000000..f7fdd6817b --- /dev/null +++ b/src/sharedInterfaces/publishDialog.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FormItemSpec, FormState, FormReducers } from "./form"; +import { RequestType } from "vscode-jsonrpc/browser"; + +/** + * Data fields shown in the Publish form. + */ +export interface IPublishForm { + profileName?: string; + serverName?: string; + databaseName?: string; + publishTarget?: "existingServer" | "localContainer"; + sqlCmdVariables?: { [key: string]: string }; +} + +/** + * Webview state for the Publish dialog (form + runtime fields). + */ +export interface PublishDialogWebviewState + extends FormState { + message: any; + projectFilePath: string; + inProgress: boolean; + lastPublishResult?: any; +} + +/** + * Form item specification for Publish dialog fields. + */ +export type PublishDialogFormItemSpec = FormItemSpec< + IPublishForm, + PublishDialogWebviewState, + PublishDialogFormItemSpec +>; + +/** + * Reducers (messages) the controller supports in addition to the generic form actions. + */ +export interface PublishDialogReducers extends FormReducers { + setPublishValues: { + profileName?: string; + serverName?: string; + databaseName?: string; + publishTarget?: "existingServer" | "localContainer"; + sqlCmdVariables?: { [key: string]: string }; + projectFilePath?: string; + }; + + publishNow: { + projectFilePath?: string; + databaseName?: string; + connectionUri?: string; + sqlCmdVariables?: { [key: string]: string }; + publishProfilePath?: string; + }; + generatePublishScript: {}; + openPublishAdvanced: {}; + cancelPublish: {}; + selectPublishProfile: {}; + savePublishProfile: { profileName: string }; +} + +/** + * Example request pattern retained for future preview scenarios. + */ +export namespace GetPublishPreviewRequest { + export const type = new RequestType("getPublishPreview"); +} From 94f8dae5cb6242979b6289ddcec76f8064105b3a Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 16 Sep 2025 01:06:47 -0500 Subject: [PATCH 03/94] advanced options added --- src/constants/locConstants.ts | 2 + src/controllers/mainController.ts | 7 +- src/publishProject/formComponentHelpers.ts | 136 ++++++++++ .../publishProjectWebViewController.ts | 149 +++++++---- src/reactviews/common/locConstants.ts | 1 + .../components/PublishProfile.tsx | 12 +- .../publishAdvancedOptionsDrawer.tsx | 241 ++++++++++++++++++ .../pages/PublishProject/publishProject.tsx | 71 +++--- src/sharedInterfaces/publishDialog.ts | 17 +- 9 files changed, 533 insertions(+), 103 deletions(-) create mode 100644 src/publishProject/formComponentHelpers.ts create mode 100644 src/reactviews/pages/PublishProject/components/publishAdvancedOptionsDrawer.tsx diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index 6595384f1c..e761612c4f 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1193,6 +1193,8 @@ export const PublishProject = { GenerateScript: "Generate Script", Publish: "Publish", Cancel: "Cancel", + PublishOptions: "Publish Options", + ExcludeOptions: "Exclude Options", }; export class SchemaCompare { diff --git a/src/controllers/mainController.ts b/src/controllers/mainController.ts index 7652f313fc..f054c2ece6 100644 --- a/src/controllers/mainController.ts +++ b/src/controllers/mainController.ts @@ -2629,13 +2629,16 @@ export default class MainController implements vscode.Disposable { * This method normalizes the arguments and launches the Schema Compare UI. */ public async onPublishDatabaseProject(projectFilePath: string): Promise { - const schemaCompareWebView = new PublishProjectWebViewController( + const defaultsPublishOptions = + await this.schemaCompareService.schemaCompareGetDefaultOptions(); + const publishProjectWebView = new PublishProjectWebViewController( this._context, this._vscodeWrapper, projectFilePath, + defaultsPublishOptions, ); - schemaCompareWebView.revealToForeground(); + publishProjectWebView.revealToForeground(); } /** diff --git a/src/publishProject/formComponentHelpers.ts b/src/publishProject/formComponentHelpers.ts new file mode 100644 index 0000000000..9dbd600d5c --- /dev/null +++ b/src/publishProject/formComponentHelpers.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as mssql from "vscode-mssql"; +import { FormItemType } from "../sharedInterfaces/form"; +import { + IPublishForm, + PublishDialogFormItemSpec, + PublishDialogWebviewState, +} from "../sharedInterfaces/publishDialog"; +import { PublishProject as Loc } from "../constants/locConstants"; + +/** + * Generate publish form components. Kept async to mirror the connection pattern and allow + * future async population of options (e.g. reading project metadata or remote targets). + */ +export async function generatePublishFormComponents( + schemaCompareDefaults?: mssql.SchemaCompareOptionsResult, +): Promise> { + const components: Record = { + profileName: { + propertyName: "profileName", + label: Loc.ProfileLabel, + required: false, + type: FormItemType.Input, + isAdvancedOption: false, + }, + serverName: { + propertyName: "serverName", + label: Loc.ServerLabel, + required: false, + type: FormItemType.Input, + isAdvancedOption: false, + }, + databaseName: { + propertyName: "databaseName", + label: Loc.DatabaseLabel, + required: true, + type: FormItemType.Input, + isAdvancedOption: false, + validate: (_state: PublishDialogWebviewState, value: string) => { + const isValid = (value ?? "").trim().length > 0; + return { isValid, validationMessage: isValid ? "" : Loc.DatabaseRequiredMessage }; + }, + }, + publishTarget: { + propertyName: "publishTarget", + label: Loc.PublishTargetLabel, + required: true, + type: FormItemType.Dropdown, + isAdvancedOption: false, + options: [ + { + displayName: Loc.PublishTargetExisting, + value: "existingServer", + }, + { + displayName: Loc.PublishTargetContainer, + value: "localContainer", + }, + ], + }, + } as Record; + + // If schema-compare defaults were provided, add dynamic components for them + const defaults = schemaCompareDefaults?.defaultDeploymentOptions; + + if (defaults) { + // Add boolean options as individual checkbox components grouped under 'Publish Options' + const bools = defaults.booleanOptionsDictionary ?? {}; + for (const key of Object.keys(bools)) { + components[key] = { + propertyName: key as string, + label: bools[key].displayName ?? key, + required: false, + type: FormItemType.Checkbox, + tooltip: bools[key].description ?? undefined, + isAdvancedOption: true, + optionCategory: "publishOptions", + optionCategoryLabel: Loc.PublishOptions, + } as PublishDialogFormItemSpec; + } + + // Add object-type exclusion options as checkboxes grouped under 'Exclude Options' + const objectTypes = defaults.objectTypesDictionary ?? {}; + for (const key of Object.keys(objectTypes)) { + const propName = `exclude_${key}`; + components[propName] = { + propertyName: propName as string, + label: objectTypes[key] ?? key, + required: false, + type: FormItemType.Checkbox, + tooltip: undefined, + isAdvancedOption: true, + optionCategory: "excludeOptions", + optionCategoryLabel: Loc.ExcludeOptions, + } as PublishDialogFormItemSpec; + } + } + + // allow future async population here + return components; +} + +export interface ComponentGroup { + groupName?: string; + options: (keyof IPublishForm)[]; +} + +/** + * Simple grouping helper that mimics the connection dialog behavior: return a single + * advanced group that contains all advanced options, but leave the helper extensible. + */ +export function groupAdvancedOptions( + components: Record, +): ComponentGroup[] { + const groupMap: Map = new Map(); + + for (const option of Object.values(components)) { + if (!option.isAdvancedOption) { + continue; + } + const category = option.optionCategory; + const categoryLabel = + option.optionCategoryLabel ?? + (category === "publishOptions" ? Loc.PublishOptions : Loc.ExcludeOptions); + if (!groupMap.has(category)) { + groupMap.set(category, { groupName: categoryLabel, options: [] }); + } + groupMap.get(category)!.options.push(option.propertyName as any); + } + + return Array.from(groupMap.values()); +} diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index e4934f5f05..90d39d1f89 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -7,7 +7,7 @@ components exactly like the Connection dialog. */ import * as vscode from "vscode"; -import { FormItemType } from "../sharedInterfaces/form"; +import * as mssql from "vscode-mssql"; import { FormWebviewController } from "../forms/formWebviewController"; import VscodeWrapper from "../controllers/vscodeWrapper"; import { PublishProject as Loc } from "../constants/locConstants"; @@ -17,6 +17,7 @@ import { PublishDialogFormItemSpec, IPublishForm, } from "../sharedInterfaces/publishDialog"; +import { generatePublishFormComponents, groupAdvancedOptions } from "./formComponentHelpers"; export class PublishProjectWebViewController extends FormWebviewController< IPublishForm, @@ -24,10 +25,18 @@ export class PublishProjectWebViewController extends FormWebviewController< PublishDialogFormItemSpec, PublishDialogReducers > { + public static mainOptions: readonly (keyof IPublishForm)[] = [ + "publishTarget", + "profileName", + "serverName", + "databaseName", + ]; + constructor( context: vscode.ExtensionContext, _vscodeWrapper: VscodeWrapper, projectFilePath: string, + schemaCompareOptionsResult?: mssql.SchemaCompareOptionsResult, ) { const initialFormState: IPublishForm = { profileName: "", @@ -37,61 +46,21 @@ export class PublishProjectWebViewController extends FormWebviewController< sqlCmdVariables: {}, }; - const formComponents: Partial> = { - profileName: { - propertyName: "profileName", - type: FormItemType.Input, - label: Loc.ProfileLabel, - required: false, - placeholder: Loc.ProfilePlaceholder ?? "", - }, - serverName: { - propertyName: "serverName", - type: FormItemType.Input, - label: Loc.ServerLabel, - required: false, - placeholder: Loc.ServerLabel ?? "", - }, - databaseName: { - propertyName: "databaseName", - type: FormItemType.Input, - label: Loc.DatabaseLabel, - required: true, - placeholder: Loc.DatabaseLabel ?? "", - validate: (_state, value) => { - const isValid = (value as string).trim().length > 0; - return { - isValid, - validationMessage: isValid ? "" : (Loc.DatabaseRequiredMessage ?? ""), - }; - }, - }, - publishTarget: { - propertyName: "publishTarget", - type: FormItemType.Dropdown, - label: Loc.PublishTargetLabel, - required: true, - options: [ - { - displayName: Loc.PublishTargetExisting ?? "Existing SQL server", - value: "existingServer", - }, - { - displayName: Loc.PublishTargetContainer ?? "Local development container", - value: "localContainer", - }, - ], - }, - }; - const initialState: PublishDialogWebviewState = { formState: initialFormState, - formComponents, + formComponents: {}, projectFilePath, inProgress: false, lastPublishResult: undefined, message: undefined, - }; + connectionComponents: { + mainOptions: [ + ...(PublishProjectWebViewController.mainOptions as (keyof IPublishForm)[]), + ], + groupedAdvancedOptions: [], + } as any, + defaultDeploymentOptionsResult: schemaCompareOptionsResult, + } as PublishDialogWebviewState; super(context, _vscodeWrapper, "publishDialog", "publishDialog", initialState, { title: Loc.Title, @@ -105,6 +74,67 @@ export class PublishProjectWebViewController extends FormWebviewController< ), }, }); + + // async initialize so component generation can be async (mirrors connection dialog pattern) + void this.initializeDialog(projectFilePath); + } + + private async initializeDialog(projectFilePath: string) { + // Load publish form components + this.state.formComponents = await generatePublishFormComponents( + this.state.defaultDeploymentOptionsResult, + ); + + // Configure which items are main vs advanced + this.state.connectionComponents = { + mainOptions: [...PublishProjectWebViewController.mainOptions], + groupedAdvancedOptions: [], + } as any; + + this.state.connectionComponents.groupedAdvancedOptions = groupAdvancedOptions( + this.state.formComponents as Record, + ); + + // if schema compare defaults were passed in, map matching option keys into formState + const defaults = this.state.defaultDeploymentOptionsResult?.defaultDeploymentOptions; + if (defaults) { + // boolean options -> set matching form fields + const bools = defaults.booleanOptionsDictionary ?? {}; + for (const key of Object.keys(bools)) { + if ((this.state.formComponents as any)[key]) { + // @ts-ignore set only when a corresponding form field exists + (this.state.formState as any)[key] = bools[key].value; + } + } + + // excludeObjectTypes -> if publish form has matching control + if ( + (defaults as mssql.DeploymentOptions).excludeObjectTypes && + (this.state.formComponents as any)["excludeObjectTypes"] + ) { + (this.state.formState as any)["excludeObjectTypes"] = ( + defaults as any + ).excludeObjectTypes.value; + } + + // exclude object types defaults -> set per-object checkbox components (exclude_{type}) + const excludedList: string[] = (defaults as any).excludeObjectTypes?.value ?? []; + const objectTypes = defaults.objectTypesDictionary ?? {}; + for (const key of Object.keys(objectTypes)) { + const propName = `exclude_${key}`; + if ((this.state.formComponents as any)[propName]) { + (this.state.formState as any)[propName] = excludedList.includes(key); + } + } + } + + // keep initial project path and computed database name + if (projectFilePath) { + this.state.projectFilePath = projectFilePath; + } + + await this.updateItemVisibility(); + this.updateState(); } protected get reducers() { @@ -173,15 +203,22 @@ export class PublishProjectWebViewController extends FormWebviewController< } protected getActiveFormComponents(_state: PublishDialogWebviewState) { - return [ - "publishTarget", - "profileName", - "serverName", - "databaseName", - ] as (keyof IPublishForm)[]; + return [...PublishProjectWebViewController.mainOptions]; } public async updateItemVisibility(): Promise { + const hidden: (keyof IPublishForm)[] = []; + + // Example visibility: local container target doesn't require a server name + if (this.state.formState?.publishTarget === "localContainer") { + hidden.push("serverName"); + } + + for (const component of Object.values(this.state.formComponents)) { + // mark hidden if the property is in hidden list + component.hidden = hidden.includes(component.propertyName as keyof IPublishForm); + } + return; } } diff --git a/src/reactviews/common/locConstants.ts b/src/reactviews/common/locConstants.ts index 76512d772b..77a352151a 100644 --- a/src/reactviews/common/locConstants.ts +++ b/src/reactviews/common/locConstants.ts @@ -883,6 +883,7 @@ export class LocConstants { SelectProfile: l10n.t("Select Profile"), SaveAs: l10n.t("Save As..."), advanced: l10n.t("Advanced"), + advancedOptions: l10n.t("Advanced Publish Options"), generateScript: l10n.t("Generate Script"), publish: l10n.t("Publish"), cancel: l10n.t("Cancel"), diff --git a/src/reactviews/pages/PublishProject/components/PublishProfile.tsx b/src/reactviews/pages/PublishProject/components/PublishProfile.tsx index e30983665e..09bef822a6 100644 --- a/src/reactviews/pages/PublishProject/components/PublishProfile.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishProfile.tsx @@ -5,7 +5,7 @@ import { Button, makeStyles } from "@fluentui/react-components"; import { useContext } from "react"; -import { FormField } from "../../../common/forms/form.component"; +import { FormField, useFormStyles } from "../../../common/forms/form.component"; import { LocConstants } from "../../../common/locConstants"; import { IPublishForm, @@ -30,8 +30,8 @@ type PublishFormContext = FormContextProps< const useStyles = makeStyles({ root: { display: "flex", - flexDirection: "row", - alignItems: "flex-end", + flexDirection: "column", + alignItems: "stretch", gap: "8px", maxWidth: "640px", width: "100%", @@ -41,6 +41,7 @@ const useStyles = makeStyles({ flexDirection: "row", gap: "4px", paddingBottom: "4px", + alignSelf: "flex-end", }, fieldContainer: { flexGrow: 1, @@ -55,17 +56,18 @@ const useStyles = makeStyles({ export default function PublishProfileField(props: { idx: number }) { const { idx } = props; const classes = useStyles(); + const formStyles = useFormStyles(); const loc = LocConstants.getInstance().publishProject; const context = useContext(PublishProjectContext) as PublishFormContext | undefined; if (!context || !context.state) { - return null; + return undefined; } const component = context.state.formComponents.profileName as PublishDialogFormItemSpec; return ( -
+
div[role="checkbox"]': { + flex: "0 0 auto", + marginLeft: "4px", + }, + ".checkboxLabel": { + flex: 1, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }, + }, + searchBox: { + width: "100%", + margin: "4px 0 8px 0", + "> div": { + width: "100%", + }, + }, +}); + +type AdvancedOptionGroup = { groupName?: string; options: (keyof IPublishForm)[] }; + +export const PublishAdvancedOptionsDrawer = ({ + isOpen, + onDismiss, +}: { + isOpen: boolean; + onDismiss: () => void; +}) => { + const context = useContext(PublishProjectContext) as + | FormContextProps + | undefined; + const [searchSettingsText, setSearchSettingsText] = useState(""); + const [userOpenedSections, setUserOpenedSections] = useState([]); + const accordionStyles = useAccordionStyles(); + const styles = useStyles(); + + if (!context || !context.state) { + return undefined; + } + + const groupedOptions: AdvancedOptionGroup[] = + context.state.connectionComponents?.groupedAdvancedOptions ?? []; + + const isOptionVisible = (option: PublishDialogFormItemSpec) => { + if (!searchSettingsText) { + return true; + } + const text = searchSettingsText.toLowerCase(); + return ( + option.label.toLowerCase().includes(text) || + String(option.propertyName).toLowerCase().includes(text) + ); + }; + + const doesGroupHaveVisibleOptions = (group: AdvancedOptionGroup) => + group.options.some((name) => + isOptionVisible(context.state.formComponents[name] as PublishDialogFormItemSpec), + ); + + return ( + !open && onDismiss()}> + + } + onClick={() => onDismiss()} + /> + }> + {locConstants.publishProject.advancedOptions} + + + +
+ setSearchSettingsText(data.value ?? "")} + value={searchSettingsText} + /> +
+ { + if (!searchSettingsText) { + setUserOpenedSections(data.openItems as string[]); + } + }} + openItems={ + searchSettingsText + ? groupedOptions.map((g) => g.groupName) + : userOpenedSections + }> + {groupedOptions + .filter(doesGroupHaveVisibleOptions) + .sort((a, b) => (a.groupName ?? "").localeCompare(b.groupName ?? "")) + .map((group, groupIndex) => ( + + {group.groupName} + + {group.options + .filter((name) => + isOptionVisible( + context.state.formComponents[ + name + ] as PublishDialogFormItemSpec, + ), + ) + .sort((aName, bName) => { + const aLabel = ( + context.state.formComponents[aName]!.label ?? "" + ).toString(); + const bLabel = ( + context.state.formComponents[bName]!.label ?? "" + ).toString(); + return aLabel.localeCompare(bLabel); + }) + .map((name, idx) => { + const component = context.state.formComponents[ + name + ] as PublishDialogFormItemSpec; + if (component.type === "checkbox") { + const checked = Boolean( + context.state.formState[component.propertyName], + ); + const id = `adv-${String(component.propertyName)}`; + const labelContent = ( + + ); + return ( +
+ + context.formAction({ + propertyName: + component.propertyName, + isAction: false, + value: data.checked, + }) + } + aria-labelledby={`${id}-lbl`} + /> + {component.tooltip ? ( + + {labelContent} + + ) : ( + labelContent + )} +
+ ); + } + return ( + + > + key={idx} + context={context} + component={component} + idx={idx} + /> + ); + })} +
+
+ ))} +
+
+
+ ); +}; diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index c3f6f7e1ae..152cc9892d 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -7,7 +7,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { useContext } from "react"; +import { useContext, useState } from "react"; import { Button, makeStyles } from "@fluentui/react-components"; import { FormField, useFormStyles } from "../../common/forms/form.component"; import { PublishProjectStateProvider, PublishProjectContext } from "./publishProjectStateProvider"; @@ -19,6 +19,7 @@ import { } from "../../../sharedInterfaces/publishDialog"; import { FormContextProps } from "../../../sharedInterfaces/form"; import PublishProfileField from "./components/PublishProfile"; +import { PublishAdvancedOptionsDrawer } from "./components/publishAdvancedOptionsDrawer"; const useStyles = makeStyles({ root: { padding: "12px" }, @@ -53,6 +54,7 @@ function PublishProjectInner() { const formStyles = useFormStyles(); const loc = LocConstants.getInstance().publishProject; const context = useContext(PublishProjectContext) as PublishFormContext | undefined; + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); if (!context || !context.state) { return
Loading...
; @@ -64,45 +66,44 @@ function PublishProjectInner() {
e.preventDefault()}>
- - context={context} - component={state.formComponents.publishTarget as PublishDialogFormItemSpec} - idx={0} - props={{ orientation: "horizontal" }} - /> + {state.connectionComponents?.mainOptions.map((optionName, idx) => { + if (!optionName) { + return null; + } - + if ((optionName as string) === "profileName") { + return ; + } - - context={context} - component={state.formComponents.serverName as PublishDialogFormItemSpec} - idx={2} - props={{ orientation: "horizontal" }} - /> - - context={context} - component={state.formComponents.databaseName as PublishDialogFormItemSpec} - idx={3} - props={{ orientation: "horizontal" }} + const component = state.formComponents[ + optionName as keyof IPublishForm + ] as PublishDialogFormItemSpec; + if (!component || component.hidden === true) { + return null; + } + return ( + + key={String(optionName)} + context={context} + component={component} + idx={idx} + props={{ orientation: "horizontal" }} + /> + ); + })} + + setIsAdvancedOpen(false)} />
-
diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index f7fdd6817b..3ca551469c 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -5,6 +5,7 @@ import { FormItemSpec, FormState, FormReducers } from "./form"; import { RequestType } from "vscode-jsonrpc/browser"; +import * as mssql from "vscode-mssql"; /** * Data fields shown in the Publish form. @@ -26,16 +27,22 @@ export interface PublishDialogWebviewState projectFilePath: string; inProgress: boolean; lastPublishResult?: any; + connectionComponents?: { + mainOptions: (keyof IPublishForm)[]; + groupedAdvancedOptions?: { groupName?: string; options: (keyof IPublishForm)[] }[]; + }; + defaultDeploymentOptionsResult?: mssql.SchemaCompareOptionsResult; } /** * Form item specification for Publish dialog fields. */ -export type PublishDialogFormItemSpec = FormItemSpec< - IPublishForm, - PublishDialogWebviewState, - PublishDialogFormItemSpec ->; +export interface PublishDialogFormItemSpec + extends FormItemSpec { + isAdvancedOption?: boolean; + optionCategory?: string; + optionCategoryLabel?: string; +} /** * Reducers (messages) the controller supports in addition to the generic form actions. From 463b2e84f5ce5fee0df9e4cb3abc92279c02d2d7 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 16 Sep 2025 13:04:24 -0500 Subject: [PATCH 04/94] basic UI final changes --- src/publishProject/formComponentHelpers.ts | 68 ++++++++++++++++++ .../components/PublishProfile.tsx | 12 ++-- .../pages/PublishProject/publishProject.tsx | 72 ++++++++----------- 3 files changed, 103 insertions(+), 49 deletions(-) create mode 100644 src/publishProject/formComponentHelpers.ts diff --git a/src/publishProject/formComponentHelpers.ts b/src/publishProject/formComponentHelpers.ts new file mode 100644 index 0000000000..48cadb9780 --- /dev/null +++ b/src/publishProject/formComponentHelpers.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FormItemType } from "../sharedInterfaces/form"; +import { + IPublishForm, + PublishDialogFormItemSpec, + PublishDialogWebviewState, +} from "../sharedInterfaces/publishDialog"; +import { PublishProject as Loc } from "../constants/locConstants"; + +/** + * Generate publish form components. Kept async to mirror the connection pattern and allow + * future async population of options (e.g. reading project metadata or remote targets). + */ +export async function generatePublishFormComponents(): Promise< + Record +> { + const components: Record = { + profileName: { + propertyName: "profileName", + label: Loc.ProfileLabel, + required: false, + type: FormItemType.Input, + isAdvancedOption: false, + }, + serverName: { + propertyName: "serverName", + label: Loc.ServerLabel, + required: false, + type: FormItemType.Input, + isAdvancedOption: false, + }, + databaseName: { + propertyName: "databaseName", + label: Loc.DatabaseLabel, + required: true, + type: FormItemType.Input, + isAdvancedOption: false, + validate: (_state: PublishDialogWebviewState, value: string) => { + const isValid = (value ?? "").trim().length > 0; + return { isValid, validationMessage: isValid ? "" : Loc.DatabaseRequiredMessage }; + }, + }, + publishTarget: { + propertyName: "publishTarget", + label: Loc.PublishTargetLabel, + required: true, + type: FormItemType.Dropdown, + isAdvancedOption: false, + options: [ + { + displayName: Loc.PublishTargetExisting, + value: "existingServer", + }, + { + displayName: Loc.PublishTargetContainer, + value: "localContainer", + }, + ], + }, + } as Record; + + // allow future async population here + return components; +} diff --git a/src/reactviews/pages/PublishProject/components/PublishProfile.tsx b/src/reactviews/pages/PublishProject/components/PublishProfile.tsx index e30983665e..09bef822a6 100644 --- a/src/reactviews/pages/PublishProject/components/PublishProfile.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishProfile.tsx @@ -5,7 +5,7 @@ import { Button, makeStyles } from "@fluentui/react-components"; import { useContext } from "react"; -import { FormField } from "../../../common/forms/form.component"; +import { FormField, useFormStyles } from "../../../common/forms/form.component"; import { LocConstants } from "../../../common/locConstants"; import { IPublishForm, @@ -30,8 +30,8 @@ type PublishFormContext = FormContextProps< const useStyles = makeStyles({ root: { display: "flex", - flexDirection: "row", - alignItems: "flex-end", + flexDirection: "column", + alignItems: "stretch", gap: "8px", maxWidth: "640px", width: "100%", @@ -41,6 +41,7 @@ const useStyles = makeStyles({ flexDirection: "row", gap: "4px", paddingBottom: "4px", + alignSelf: "flex-end", }, fieldContainer: { flexGrow: 1, @@ -55,17 +56,18 @@ const useStyles = makeStyles({ export default function PublishProfileField(props: { idx: number }) { const { idx } = props; const classes = useStyles(); + const formStyles = useFormStyles(); const loc = LocConstants.getInstance().publishProject; const context = useContext(PublishProjectContext) as PublishFormContext | undefined; if (!context || !context.state) { - return null; + return undefined; } const component = context.state.formComponents.profileName as PublishDialogFormItemSpec; return ( -
+
e.preventDefault()}>
- - context={context} - component={state.formComponents.publishTarget as PublishDialogFormItemSpec} - idx={0} - props={{ orientation: "horizontal" }} - /> + {state.connectionComponents?.mainOptions.map((optionName, idx) => { + if (!optionName) { + return null; + } - + if ((optionName as string) === "profileName") { + return ; + } - - context={context} - component={state.formComponents.serverName as PublishDialogFormItemSpec} - idx={2} - props={{ orientation: "horizontal" }} - /> - - context={context} - component={state.formComponents.databaseName as PublishDialogFormItemSpec} - idx={3} - props={{ orientation: "horizontal" }} - /> - -
- -
+ const component = state.formComponents[ + optionName as keyof IPublishForm + ] as PublishDialogFormItemSpec; + if (!component || component.hidden === true) { + return null; + } + return ( + + key={String(optionName)} + context={context} + component={component} + idx={idx} + props={{ orientation: "horizontal" }} + /> + ); + })}
-
diff --git a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx index cf518fbb35..c617d16e91 100644 --- a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx +++ b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx @@ -5,6 +5,7 @@ import { createContext } from "react"; import { useVscodeWebview } from "../../common/vscodeWebviewProvider"; +import { WebviewRpc } from "../../common/rpc"; import { PublishDialogWebviewState, PublishDialogReducers, @@ -12,17 +13,32 @@ import { interface PublishProjectContextValue { state?: PublishDialogWebviewState; - formAction: (event: any) => void; - publishNow: (payload?: any) => void; + formAction: (event: PublishFormActionEvent) => void; + publishNow: (payload?: PublishNowPayload) => void; generatePublishScript: () => void; - openPublishAdvanced: () => void; - cancelPublish: () => void; selectPublishProfile: () => void; savePublishProfile: (profileName: string) => void; setPublishValues: ( values: Partial & { projectFilePath?: string }, ) => void; - extensionRpc?: any; + extensionRpc?: WebviewRpc; +} + +// Event payload coming from shared FormField components +export interface PublishFormActionEvent { + propertyName: keyof PublishDialogWebviewState["formState"]; + value: string | boolean; + isAction: boolean; // true when triggered by an action button on the field + updateValidation?: boolean; // optional flag to force validation +} + +// Optional payload for publishNow future expansion +export interface PublishNowPayload { + projectFilePath?: string; + databaseName?: string; + connectionUri?: string; + sqlCmdVariables?: { [key: string]: string }; + publishProfilePath?: string; } const PublishProjectContext = createContext(undefined); @@ -33,18 +49,15 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } const webviewContext = useVscodeWebview(); const state = webviewContext?.state; - const formAction = (event: any) => webviewContext?.extensionRpc.action("formAction", { event }); + const formAction = (event: PublishFormActionEvent) => + webviewContext?.extensionRpc.action("formAction", { event }); - const publishNow = (payload?: any) => + const publishNow = (payload?: PublishNowPayload) => webviewContext?.extensionRpc.action("publishNow", payload); const generatePublishScript = () => webviewContext?.extensionRpc.action("generatePublishScript"); - const openPublishAdvanced = () => webviewContext?.extensionRpc.action("openPublishAdvanced"); - - const cancelPublish = () => webviewContext?.extensionRpc.action("cancelPublish"); - const selectPublishProfile = () => webviewContext?.extensionRpc.action("selectPublishProfile"); const savePublishProfile = (profileName: string) => @@ -61,8 +74,6 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } formAction, publishNow, generatePublishScript, - openPublishAdvanced, - cancelPublish, selectPublishProfile, savePublishProfile, setPublishValues, diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index f7fdd6817b..2d525f3a2c 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -17,15 +17,11 @@ export interface IPublishForm { sqlCmdVariables?: { [key: string]: string }; } -/** - * Webview state for the Publish dialog (form + runtime fields). - */ export interface PublishDialogWebviewState extends FormState { - message: any; projectFilePath: string; inProgress: boolean; - lastPublishResult?: any; + lastPublishResult?: { success: boolean; details?: string }; } /** @@ -59,7 +55,6 @@ export interface PublishDialogReducers extends FormReducers { }; generatePublishScript: {}; openPublishAdvanced: {}; - cancelPublish: {}; selectPublishProfile: {}; savePublishProfile: { profileName: string }; } From 6e1970cf67729c2c395b65be39c72f60b243a4c3 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 16 Sep 2025 15:38:07 -0500 Subject: [PATCH 07/94] removed Advanced related changes from the current set --- .../publishProjectWebViewController.ts | 5 - .../publishAdvancedOptionsDrawer.tsx | 241 ------------------ .../pages/PublishProject/publishProject.tsx | 2 - 3 files changed, 248 deletions(-) delete mode 100644 src/reactviews/pages/PublishProject/components/publishAdvancedOptionsDrawer.tsx diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 7ee3125e23..c0bfd65f1a 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -117,11 +117,6 @@ export class PublishProjectWebViewController extends FormWebviewController< return state; }); - reducerMap.set("openPublishAdvanced", async (state: PublishDialogWebviewState) => { - this.updateState(state); - return state; - }); - reducerMap.set("selectPublishProfile", async (state: PublishDialogWebviewState) => { this.updateState(state); return state; diff --git a/src/reactviews/pages/PublishProject/components/publishAdvancedOptionsDrawer.tsx b/src/reactviews/pages/PublishProject/components/publishAdvancedOptionsDrawer.tsx deleted file mode 100644 index 5fd3478ff7..0000000000 --- a/src/reactviews/pages/PublishProject/components/publishAdvancedOptionsDrawer.tsx +++ /dev/null @@ -1,241 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { - Accordion, - AccordionHeader, - AccordionItem, - AccordionPanel, - Button, - Checkbox, - DrawerBody, - DrawerHeader, - DrawerHeaderTitle, - InfoLabel, - OverlayDrawer, - SearchBox, - makeStyles, -} from "@fluentui/react-components"; -import { Dismiss24Regular } from "@fluentui/react-icons"; -import { useContext, useState } from "react"; -import { FormField } from "../../../common/forms/form.component"; -import { PublishProjectContext } from "../publishProjectStateProvider"; -import { - IPublishForm, - PublishDialogFormItemSpec, - PublishDialogWebviewState, -} from "../../../../sharedInterfaces/publishDialog"; -import { FormContextProps } from "../../../../sharedInterfaces/form"; -import { locConstants } from "../../../common/locConstants"; -import { useAccordionStyles } from "../../../common/styles"; - -const useStyles = makeStyles({ - checkboxRow: { - display: "flex", - flexDirection: "row", - alignItems: "center", - gap: "8px", - width: "100%", - // Target fluent checkbox root - '> div[role="checkbox"]': { - flex: "0 0 auto", - marginLeft: "4px", - }, - ".checkboxLabel": { - flex: 1, - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", - }, - }, - searchBox: { - width: "100%", - margin: "4px 0 8px 0", - "> div": { - width: "100%", - }, - }, -}); - -type AdvancedOptionGroup = { groupName?: string; options: (keyof IPublishForm)[] }; - -export const PublishAdvancedOptionsDrawer = ({ - isOpen, - onDismiss, -}: { - isOpen: boolean; - onDismiss: () => void; -}) => { - const context = useContext(PublishProjectContext) as - | FormContextProps - | undefined; - const [searchSettingsText, setSearchSettingsText] = useState(""); - const [userOpenedSections, setUserOpenedSections] = useState([]); - const accordionStyles = useAccordionStyles(); - const styles = useStyles(); - - if (!context || !context.state) { - return undefined; - } - - const groupedOptions: AdvancedOptionGroup[] = - context.state.connectionComponents?.groupedAdvancedOptions ?? []; - - const isOptionVisible = (option: PublishDialogFormItemSpec) => { - if (!searchSettingsText) { - return true; - } - const text = searchSettingsText.toLowerCase(); - return ( - option.label.toLowerCase().includes(text) || - String(option.propertyName).toLowerCase().includes(text) - ); - }; - - const doesGroupHaveVisibleOptions = (group: AdvancedOptionGroup) => - group.options.some((name) => - isOptionVisible(context.state.formComponents[name] as PublishDialogFormItemSpec), - ); - - return ( - !open && onDismiss()}> - - } - onClick={() => onDismiss()} - /> - }> - {locConstants.publishProject.advancedOptions} - - - -
- setSearchSettingsText(data.value ?? "")} - value={searchSettingsText} - /> -
- { - if (!searchSettingsText) { - setUserOpenedSections(data.openItems as string[]); - } - }} - openItems={ - searchSettingsText - ? groupedOptions.map((g) => g.groupName) - : userOpenedSections - }> - {groupedOptions - .filter(doesGroupHaveVisibleOptions) - .sort((a, b) => (a.groupName ?? "").localeCompare(b.groupName ?? "")) - .map((group, groupIndex) => ( - - {group.groupName} - - {group.options - .filter((name) => - isOptionVisible( - context.state.formComponents[ - name - ] as PublishDialogFormItemSpec, - ), - ) - .sort((aName, bName) => { - const aLabel = ( - context.state.formComponents[aName]!.label ?? "" - ).toString(); - const bLabel = ( - context.state.formComponents[bName]!.label ?? "" - ).toString(); - return aLabel.localeCompare(bLabel); - }) - .map((name, idx) => { - const component = context.state.formComponents[ - name - ] as PublishDialogFormItemSpec; - if (component.type === "checkbox") { - const checked = Boolean( - context.state.formState[component.propertyName], - ); - const id = `adv-${String(component.propertyName)}`; - const labelContent = ( - - ); - return ( -
- - context.formAction({ - propertyName: - component.propertyName, - isAction: false, - value: data.checked, - }) - } - aria-labelledby={`${id}-lbl`} - /> - {component.tooltip ? ( - - {labelContent} - - ) : ( - labelContent - )} -
- ); - } - return ( - - > - key={idx} - context={context} - component={component} - idx={idx} - /> - ); - })} -
-
- ))} -
-
-
- ); -}; diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index c5c220c046..ee892fa46c 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -15,7 +15,6 @@ import { } from "../../../sharedInterfaces/publishDialog"; import { FormContextProps } from "../../../sharedInterfaces/form"; import PublishProfileField from "./components/PublishProfile"; -import { PublishAdvancedOptionsDrawer } from "./components/publishAdvancedOptionsDrawer"; const useStyles = makeStyles({ root: { padding: "12px" }, @@ -39,7 +38,6 @@ type PublishFormContext = FormContextProps< > & { publishNow: () => void; generatePublishScript: () => void; - openPublishAdvanced: () => void; selectPublishProfile: () => void; savePublishProfile: (profileName: string) => void; }; From 13b93a9d60d6ec81013206b295ab20c8f8c3d339 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 16 Sep 2025 16:28:30 -0500 Subject: [PATCH 08/94] converting const to static class --- src/constants/locConstants.ts | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index e761612c4f..1c4f50f699 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1177,25 +1177,22 @@ export class TableDesigner { public static AdvancedOptions = l10n.t("Advanced Options"); } -export const PublishProject = { - Title: "Publish Project", - ProfileLabel: "Publish Profile", - ProfilePlaceholder: "Select or enter a publish profile", - ServerLabel: "Server", - DatabaseLabel: "Database", - DatabaseRequiredMessage: "Database name is required", - PublishTargetLabel: "Publish Target", - PublishTargetExisting: "Existing SQL server", - PublishTargetContainer: "Local development container", - UpgradeExistingLabel: "Upgrade existing database", - BuildBeforePublishLabel: "Build project before publish", - Advanced: "Advanced...", - GenerateScript: "Generate Script", - Publish: "Publish", - Cancel: "Cancel", - PublishOptions: "Publish Options", - ExcludeOptions: "Exclude Options", -}; +export class PublishProject { + public static Title = l10n.t("Publish Project"); + public static ProfileLabel = l10n.t("Publish Profile"); + public static ProfilePlaceholder = l10n.t("Select or enter a publish profile"); + public static ServerLabel = l10n.t("Server"); + public static DatabaseLabel = l10n.t("Database"); + public static DatabaseRequiredMessage = l10n.t("Database name is required"); + public static PublishTargetLabel = l10n.t("Publish Target"); + public static PublishTargetExisting = l10n.t("Existing SQL server"); + public static PublishTargetContainer = l10n.t("Local development container"); + public static UpgradeExistingLabel = l10n.t("Upgrade existing database"); + public static BuildBeforePublishLabel = l10n.t("Build project before publish"); + public static Advanced = l10n.t("Advanced..."); + public static GenerateScript = l10n.t("Generate Script"); + public static Publish = l10n.t("Publish"); +} export class SchemaCompare { public static Title = l10n.t("Schema Compare"); From 28fc897eec017ed24ca5cd80c96ff841a2ad19a4 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 16 Sep 2025 17:56:40 -0500 Subject: [PATCH 09/94] localized files --- localization/l10n/bundle.l10n.json | 9 +++++++++ localization/xliff/vscode-mssql.xlf | 27 +++++++++++++++++++++++++++ src/constants/locConstants.ts | 3 --- src/reactviews/common/locConstants.ts | 3 --- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 41bf0c18a0..5b5c646203 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -740,6 +740,8 @@ ] }, "Processing include or exclude all differences operation.": "Processing include or exclude all differences operation.", + "Select Profile": "Select Profile", + "Save As...": "Save As...", "Create New Connection Group": "Create New Connection Group", "Edit Connection Group: {0}/{0} is the name of the connection group being edited": { "message": "Edit Connection Group: {0}", @@ -1631,6 +1633,13 @@ ] }, "General": "General", + "Publish Project": "Publish Project", + "Publish Profile": "Publish Profile", + "Select or enter a publish profile": "Select or enter a publish profile", + "Database name is required": "Database name is required", + "Publish Target": "Publish Target", + "Existing SQL server": "Existing SQL server", + "Local development container": "Local development container", "Schema Compare": "Schema Compare", "Options have changed. Recompare to see the comparison?": "Options have changed. Recompare to see the comparison?", "Failed to generate script: '{0}'/{0} is the error message returned from the generate script operation": { diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 654a07a725..7c864969ae 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -928,6 +928,9 @@ Database name + + Database name is required + Default @@ -1233,6 +1236,9 @@ Execution Plan + + Existing SQL server + Expand @@ -1855,6 +1861,9 @@ Local SQL Server database container + + Local development container + Location @@ -2334,6 +2343,15 @@ Publish Changes + + Publish Profile + + + Publish Project + + + Publish Target + Publishing Changes @@ -2510,6 +2528,9 @@ Save As + + Save As... + Save Connection Group @@ -2621,6 +2642,9 @@ Select Azure account with Key Vault access for column decryption + + Select Profile + Select Source @@ -2676,6 +2700,9 @@ Select new permission for extension: '{0}' {0} is the extension name + + Select or enter a publish profile + Select profile to remove diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index 1c4f50f699..0493845ca7 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1187,9 +1187,6 @@ export class PublishProject { public static PublishTargetLabel = l10n.t("Publish Target"); public static PublishTargetExisting = l10n.t("Existing SQL server"); public static PublishTargetContainer = l10n.t("Local development container"); - public static UpgradeExistingLabel = l10n.t("Upgrade existing database"); - public static BuildBeforePublishLabel = l10n.t("Build project before publish"); - public static Advanced = l10n.t("Advanced..."); public static GenerateScript = l10n.t("Generate Script"); public static Publish = l10n.t("Publish"); } diff --git a/src/reactviews/common/locConstants.ts b/src/reactviews/common/locConstants.ts index 77a352151a..072a269ad3 100644 --- a/src/reactviews/common/locConstants.ts +++ b/src/reactviews/common/locConstants.ts @@ -882,11 +882,8 @@ export class LocConstants { return { SelectProfile: l10n.t("Select Profile"), SaveAs: l10n.t("Save As..."), - advanced: l10n.t("Advanced"), - advancedOptions: l10n.t("Advanced Publish Options"), generateScript: l10n.t("Generate Script"), publish: l10n.t("Publish"), - cancel: l10n.t("Cancel"), }; } From 0c24390ef47d24308b5b911f7842c86d1fdfabbd Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 17 Sep 2025 09:33:24 -0500 Subject: [PATCH 10/94] copilot comments adjusted --- src/controllers/mainController.ts | 13 ++++--------- .../publishProjectWebViewController.ts | 3 --- .../PublishProject/components/PublishProfile.tsx | 9 ++++++--- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/controllers/mainController.ts b/src/controllers/mainController.ts index f054c2ece6..97b1b609df 100644 --- a/src/controllers/mainController.ts +++ b/src/controllers/mainController.ts @@ -2621,21 +2621,16 @@ export default class MainController implements vscode.Disposable { } /** - * Handler for the Schema Compare command. - * Accepts variable arguments, typically: - * - [sourceNode, targetNode, runComparison] when invoked from update Project SC or programmatically, - * - [sourceNode, undefined] when invoked from a project tree node/ server / database node, - * - [] when invoked from the command palette. - * This method normalizes the arguments and launches the Schema Compare UI. + * Handler for the Publish Database Project command. + * Accepts the project file path as an argument. + * This method launches the Publish Project UI for the specified database project. + * @param projectFilePath The file path of the database project to publish. */ public async onPublishDatabaseProject(projectFilePath: string): Promise { - const defaultsPublishOptions = - await this.schemaCompareService.schemaCompareGetDefaultOptions(); const publishProjectWebView = new PublishProjectWebViewController( this._context, this._vscodeWrapper, projectFilePath, - defaultsPublishOptions, ); publishProjectWebView.revealToForeground(); diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index c0bfd65f1a..c9b11fdd6a 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from "vscode"; -import * as mssql from "vscode-mssql"; import { FormWebviewController } from "../forms/formWebviewController"; import VscodeWrapper from "../controllers/vscodeWrapper"; import { PublishProject as Loc } from "../constants/locConstants"; @@ -33,7 +32,6 @@ export class PublishProjectWebViewController extends FormWebviewController< context: vscode.ExtensionContext, _vscodeWrapper: VscodeWrapper, projectFilePath: string, - schemaCompareOptionsResult?: mssql.SchemaCompareOptionsResult, ) { const initialFormState: IPublishForm = { profileName: "", @@ -49,7 +47,6 @@ export class PublishProjectWebViewController extends FormWebviewController< projectFilePath, inProgress: false, lastPublishResult: undefined, - defaultDeploymentOptionsResult: schemaCompareOptionsResult, } as PublishDialogWebviewState; super(context, _vscodeWrapper, "publishDialog", "publishDialog", initialState, { diff --git a/src/reactviews/pages/PublishProject/components/PublishProfile.tsx b/src/reactviews/pages/PublishProject/components/PublishProfile.tsx index 09bef822a6..5d32e2fd23 100644 --- a/src/reactviews/pages/PublishProject/components/PublishProfile.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishProfile.tsx @@ -91,9 +91,12 @@ export default function PublishProfileField(props: { idx: number }) {
From 6c85bdd5fed3ef0169839b0d8555b93bbe97055d Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Fri, 19 Sep 2025 18:36:42 -0500 Subject: [PATCH 11/94] using new webview provider --- src/publishProject/formComponentHelpers.ts | 5 +- .../publishProjectWebViewController.ts | 38 ++++----- .../components/PublishProfile.tsx | 16 ++-- src/reactviews/pages/PublishProject/index.tsx | 11 ++- .../PublishProject/publishDialogSelector.ts | 14 +++ .../pages/PublishProject/publishProject.tsx | 28 ++++-- .../publishProjectStateProvider.tsx | 85 +++++++++---------- src/sharedInterfaces/publishDialog.ts | 10 ++- 8 files changed, 115 insertions(+), 92 deletions(-) create mode 100644 src/reactviews/pages/PublishProject/publishDialogSelector.ts diff --git a/src/publishProject/formComponentHelpers.ts b/src/publishProject/formComponentHelpers.ts index a954b28c2c..c9a30892db 100644 --- a/src/publishProject/formComponentHelpers.ts +++ b/src/publishProject/formComponentHelpers.ts @@ -7,7 +7,7 @@ import { FormItemType } from "../sharedInterfaces/form"; import { IPublishForm, PublishDialogFormItemSpec, - PublishDialogWebviewState, + PublishDialogState, } from "../sharedInterfaces/publishDialog"; import { PublishProject as Loc } from "../constants/locConstants"; @@ -36,7 +36,7 @@ export async function generatePublishFormComponents(): Promise< label: Loc.DatabaseLabel, required: true, type: FormItemType.Input, - validate: (_state: PublishDialogWebviewState, value: string) => { + validate: (_state: PublishDialogState, value: string) => { const isValid = (value ?? "").trim().length > 0; return { isValid, validationMessage: isValid ? "" : Loc.DatabaseRequiredMessage }; }, @@ -59,6 +59,5 @@ export async function generatePublishFormComponents(): Promise< }, } as Record; - // allow future async population here return components; } diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index c9b11fdd6a..e254671cf9 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -4,20 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from "vscode"; +import * as path from "path"; import { FormWebviewController } from "../forms/formWebviewController"; import VscodeWrapper from "../controllers/vscodeWrapper"; import { PublishProject as Loc } from "../constants/locConstants"; import { - PublishDialogWebviewState, PublishDialogReducers, PublishDialogFormItemSpec, IPublishForm, + PublishDialogState, } from "../sharedInterfaces/publishDialog"; import { generatePublishFormComponents } from "./formComponentHelpers"; export class PublishProjectWebViewController extends FormWebviewController< IPublishForm, - PublishDialogWebviewState, + PublishDialogState, PublishDialogFormItemSpec, PublishDialogReducers > { @@ -36,18 +37,20 @@ export class PublishProjectWebViewController extends FormWebviewController< const initialFormState: IPublishForm = { profileName: "", serverName: "", - databaseName: getFileNameWithoutExt(projectFilePath), + databaseName: path.basename(projectFilePath, path.extname(projectFilePath)), publishTarget: "existingServer", sqlCmdVariables: {}, }; - const initialState: PublishDialogWebviewState = { + const innerState: PublishDialogState = { formState: initialFormState, formComponents: {}, projectFilePath, inProgress: false, lastPublishResult: undefined, - } as PublishDialogWebviewState; + } as PublishDialogState; + + const initialState: PublishDialogState = innerState; super(context, _vscodeWrapper, "publishDialog", "publishDialog", initialState, { title: Loc.Title, @@ -81,15 +84,15 @@ export class PublishProjectWebViewController extends FormWebviewController< protected get reducers() { type ReducerFn = ( - state: PublishDialogWebviewState, + state: PublishDialogState, payload: unknown, - ) => Promise; + ) => Promise; const reducerMap = new Map(); reducerMap.set( "setPublishValues", async ( - state: PublishDialogWebviewState, + state: PublishDialogState, payload: Partial & { projectFilePath?: string }, ) => { if (payload) { @@ -103,25 +106,25 @@ export class PublishProjectWebViewController extends FormWebviewController< }, ); - reducerMap.set("publishNow", async (state: PublishDialogWebviewState) => { + reducerMap.set("publishNow", async (state: PublishDialogState) => { state.inProgress = false; this.updateState(state); return state; }); - reducerMap.set("generatePublishScript", async (state: PublishDialogWebviewState) => { + reducerMap.set("generatePublishScript", async (state: PublishDialogState) => { this.updateState(state); return state; }); - reducerMap.set("selectPublishProfile", async (state: PublishDialogWebviewState) => { + reducerMap.set("selectPublishProfile", async (state: PublishDialogState) => { this.updateState(state); return state; }); reducerMap.set( "savePublishProfile", - async (state: PublishDialogWebviewState, payload: { profileName?: string }) => { + async (state: PublishDialogState, payload: { profileName?: string }) => { if (payload?.profileName) { state.formState.profileName = payload.profileName; } @@ -133,7 +136,7 @@ export class PublishProjectWebViewController extends FormWebviewController< return reducerMap; } - protected getActiveFormComponents(_state: PublishDialogWebviewState) { + protected getActiveFormComponents(_state: PublishDialogState) { return [...PublishProjectWebViewController.mainOptions]; } @@ -153,12 +156,3 @@ export class PublishProjectWebViewController extends FormWebviewController< return; } } - -function getFileNameWithoutExt(filePath: string): string { - if (!filePath) { - return ""; - } - const parts = filePath.replace(/\\/g, "/").split("/"); - const last = parts[parts.length - 1]; - return last.replace(/\.[^/.]+$/, ""); -} diff --git a/src/reactviews/pages/PublishProject/components/PublishProfile.tsx b/src/reactviews/pages/PublishProject/components/PublishProfile.tsx index 5d32e2fd23..2a4ea1a58e 100644 --- a/src/reactviews/pages/PublishProject/components/PublishProfile.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishProfile.tsx @@ -7,20 +7,20 @@ import { Button, makeStyles } from "@fluentui/react-components"; import { useContext } from "react"; import { FormField, useFormStyles } from "../../../common/forms/form.component"; import { LocConstants } from "../../../common/locConstants"; +import { PublishProjectContext } from "../publishProjectStateProvider"; +import { FormContextProps } from "../../../../sharedInterfaces/form"; import { IPublishForm, PublishDialogFormItemSpec, - PublishDialogWebviewState, + PublishDialogState, } from "../../../../sharedInterfaces/publishDialog"; -import { PublishProjectContext } from "../publishProjectStateProvider"; -import { FormContextProps } from "../../../../sharedInterfaces/form"; /** * Extended context type including the extra publish profile actions we expose. */ type PublishFormContext = FormContextProps< IPublishForm, - PublishDialogWebviewState, + PublishDialogState, PublishDialogFormItemSpec > & { selectPublishProfile?: () => void; @@ -53,17 +53,15 @@ const useStyles = makeStyles({ * Custom field wrapper for Publish Profile. * Renders the generic FormField for the text input and adds the action buttons alongside it. */ -export default function PublishProfileField(props: { idx: number }) { +export const PublishProfileField = (props: { idx: number }) => { const { idx } = props; const classes = useStyles(); const formStyles = useFormStyles(); const loc = LocConstants.getInstance().publishProject; const context = useContext(PublishProjectContext) as PublishFormContext | undefined; - if (!context || !context.state) { return undefined; } - const component = context.state.formComponents.profileName as PublishDialogFormItemSpec; return ( @@ -71,7 +69,7 @@ export default function PublishProfileField(props: { idx: number }) {
@@ -102,4 +100,4 @@ export default function PublishProfileField(props: { idx: number }) {
); -} +}; diff --git a/src/reactviews/pages/PublishProject/index.tsx b/src/reactviews/pages/PublishProject/index.tsx index 6fcab406d6..fc4201b1e2 100644 --- a/src/reactviews/pages/PublishProject/index.tsx +++ b/src/reactviews/pages/PublishProject/index.tsx @@ -5,11 +5,14 @@ import ReactDOM from "react-dom/client"; import "../../index.css"; -import { VscodeWebviewProvider } from "../../common/vscodeWebviewProvider"; +import { VscodeWebviewProvider2 } from "../../common/vscodeWebviewProvider2"; import PublishProjectPage from "./publishProject"; +import { PublishProjectStateProvider } from "./publishProjectStateProvider"; ReactDOM.createRoot(document.getElementById("root")!).render( - - - , + + + + + , ); diff --git a/src/reactviews/pages/PublishProject/publishDialogSelector.ts b/src/reactviews/pages/PublishProject/publishDialogSelector.ts new file mode 100644 index 0000000000..61e9d5c671 --- /dev/null +++ b/src/reactviews/pages/PublishProject/publishDialogSelector.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PublishDialogReducers, PublishDialogState } from "../../../sharedInterfaces/publishDialog"; +import { useVscodeSelector } from "../../common/useVscodeSelector"; + +export function usePublishDialogSelector( + selector: (state: PublishDialogState) => T, + equals: (a: T, b: T) => boolean = Object.is, +) { + return useVscodeSelector(selector, equals); +} diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index ee892fa46c..0f9ca230fe 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -7,14 +7,16 @@ import { useContext } from "react"; import { Button, makeStyles } from "@fluentui/react-components"; import { FormField, useFormStyles } from "../../common/forms/form.component"; import { PublishProjectStateProvider, PublishProjectContext } from "./publishProjectStateProvider"; +import { useVscodeSelector } from "../../common/useVscodeSelector"; import { LocConstants } from "../../common/locConstants"; import { IPublishForm, PublishDialogFormItemSpec, - PublishDialogWebviewState, + PublishDialogState, + PublishDialogReducers, } from "../../../sharedInterfaces/publishDialog"; import { FormContextProps } from "../../../sharedInterfaces/form"; -import PublishProfileField from "./components/PublishProfile"; +import { PublishProfileField } from "./components/PublishProfile"; const useStyles = makeStyles({ root: { padding: "12px" }, @@ -33,7 +35,7 @@ const useStyles = makeStyles({ type PublishFormContext = FormContextProps< IPublishForm, - PublishDialogWebviewState, + PublishDialogState, PublishDialogFormItemSpec > & { publishNow: () => void; @@ -47,13 +49,23 @@ function PublishProjectInner() { const formStyles = useFormStyles(); const loc = LocConstants.getInstance().publishProject; const context = useContext(PublishProjectContext) as PublishFormContext | undefined; + // Select pieces of state needed for this component + const formComponents = useVscodeSelector< + PublishDialogState, + PublishDialogReducers, + PublishDialogState["formComponents"] | undefined + >((s) => s.formComponents, Object.is); + const formState = useVscodeSelector< + PublishDialogState, + PublishDialogReducers, + PublishDialogState["formState"] | undefined + >((s) => s.formState, Object.is); - if (!context || !context.state) { + const loading = !context || !formComponents || !formState; + if (loading) { return
Loading...
; } - const state = context.state; - // Static list of main publish dialog options const mainOptions: (keyof IPublishForm)[] = [ "publishTarget", @@ -75,7 +87,7 @@ function PublishProjectInner() { return ; } - const component = state.formComponents[ + const component = formComponents[ optionName as keyof IPublishForm ] as PublishDialogFormItemSpec; if (!component || component.hidden === true) { @@ -84,7 +96,7 @@ function PublishProjectInner() { return ( diff --git a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx index c617d16e91..09cf8fc499 100644 --- a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx +++ b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx @@ -3,30 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createContext } from "react"; -import { useVscodeWebview } from "../../common/vscodeWebviewProvider"; +import { createContext, useMemo } from "react"; +import { useVscodeWebview2 } from "../../common/vscodeWebviewProvider2"; import { WebviewRpc } from "../../common/rpc"; import { - PublishDialogWebviewState, PublishDialogReducers, + PublishDialogState, + IPublishForm, } from "../../../sharedInterfaces/publishDialog"; -interface PublishProjectContextValue { - state?: PublishDialogWebviewState; +export interface PublishProjectContextValue { + // Use inner state directly for form system generics + state?: PublishDialogState; // snapshot accessor formAction: (event: PublishFormActionEvent) => void; publishNow: (payload?: PublishNowPayload) => void; generatePublishScript: () => void; selectPublishProfile: () => void; savePublishProfile: (profileName: string) => void; setPublishValues: ( - values: Partial & { projectFilePath?: string }, + values: Partial & { projectFilePath?: string }, ) => void; - extensionRpc?: WebviewRpc; + extensionRpc: WebviewRpc; } // Event payload coming from shared FormField components export interface PublishFormActionEvent { - propertyName: keyof PublishDialogWebviewState["formState"]; + propertyName: keyof IPublishForm; value: string | boolean; isAction: boolean; // true when triggered by an action button on the field updateValidation?: boolean; // optional flag to force validation @@ -41,47 +43,44 @@ export interface PublishNowPayload { publishProfilePath?: string; } -const PublishProjectContext = createContext(undefined); +export const PublishProjectContext = createContext( + undefined, +); export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - const webviewContext = useVscodeWebview(); - const state = webviewContext?.state; + const { extensionRpc, getSnapshot } = useVscodeWebview2< + PublishDialogState, + PublishDialogReducers + >(); - const formAction = (event: PublishFormActionEvent) => - webviewContext?.extensionRpc.action("formAction", { event }); - - const publishNow = (payload?: PublishNowPayload) => - webviewContext?.extensionRpc.action("publishNow", payload); - - const generatePublishScript = () => - webviewContext?.extensionRpc.action("generatePublishScript"); - - const selectPublishProfile = () => webviewContext?.extensionRpc.action("selectPublishProfile"); - - const savePublishProfile = (profileName: string) => - webviewContext?.extensionRpc.action("savePublishProfile", { profileName }); - - const setPublishValues = ( - values: Partial & { projectFilePath?: string }, - ) => webviewContext?.extensionRpc.action("setPublishValues", values); + const value = useMemo( + () => ({ + get state() { + const inner = getSnapshot(); // inner PublishDialogState + if (!inner || Object.keys(inner).length === 0) { + return undefined; + } + return inner; + }, + formAction: (event: PublishFormActionEvent) => + extensionRpc.action("formAction", { event }), + publishNow: (payload?: PublishNowPayload) => + extensionRpc.action("publishNow", payload ?? {}), + generatePublishScript: () => extensionRpc.action("generatePublishScript"), + selectPublishProfile: () => extensionRpc.action("selectPublishProfile"), + savePublishProfile: (profileName: string) => + extensionRpc.action("savePublishProfile", { profileName }), + setPublishValues: ( + values: Partial & { projectFilePath?: string }, + ) => extensionRpc.action("setPublishValues", values), + extensionRpc, + }), + [extensionRpc, getSnapshot], + ); return ( - - {children} - + {children} ); }; - -export { PublishProjectContext }; diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 783f9be40e..2b34a397d0 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -17,8 +17,12 @@ export interface IPublishForm { sqlCmdVariables?: { [key: string]: string }; } -export interface PublishDialogWebviewState - extends FormState { +/** + * Inner state (domain + form) analogous to ExecutionPlanState in executionPlan.ts + * Extends generic FormState so form system works unchanged. + */ +export interface PublishDialogState + extends FormState { projectFilePath: string; inProgress: boolean; lastPublishResult?: { success: boolean; details?: string }; @@ -28,7 +32,7 @@ export interface PublishDialogWebviewState * Form item specification for Publish dialog fields. */ export interface PublishDialogFormItemSpec - extends FormItemSpec { + extends FormItemSpec { isAdvancedOption?: boolean; optionCategory?: string; optionCategoryLabel?: string; From c091477823fb46dac16348410f159397cf42d0ab Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Sat, 20 Sep 2025 15:19:24 -0500 Subject: [PATCH 12/94] updating the code --- .../components/ConnectionSection.tsx | 69 +++++++++++++++++++ ...hProfile.tsx => PublishProfileSection.tsx} | 0 .../components/PublishTargetSection.tsx | 45 ++++++++++++ .../pages/PublishProject/publishProject.tsx | 62 +++-------------- 4 files changed, 125 insertions(+), 51 deletions(-) create mode 100644 src/reactviews/pages/PublishProject/components/ConnectionSection.tsx rename src/reactviews/pages/PublishProject/components/{PublishProfile.tsx => PublishProfileSection.tsx} (100%) create mode 100644 src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx new file mode 100644 index 0000000000..9f88aff59c --- /dev/null +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { useContext } from "react"; +import { PublishProjectContext } from "../publishProjectStateProvider"; +import { usePublishDialogSelector } from "../publishDialogSelector"; +import { FormField } from "../../../common/forms/form.component"; +import { + IPublishForm, + PublishDialogState, + PublishDialogFormItemSpec, +} from "../../../../sharedInterfaces/publishDialog"; +import { FormContextProps } from "../../../../sharedInterfaces/form"; + +// Context type reuse +interface PublishFormContext + extends FormContextProps { + publishNow: () => void; + generatePublishScript: () => void; + selectPublishProfile: () => void; + savePublishProfile: (profileName: string) => void; +} + +export const ConnectionSection: React.FC<{ startIdx: number }> = ({ startIdx }) => { + const context = useContext(PublishProjectContext) as PublishFormContext | undefined; + + const serverComponent = usePublishDialogSelector((s) => s.formComponents.serverName, Object.is); + const databaseComponent = usePublishDialogSelector( + (s) => s.formComponents.databaseName, + Object.is, + ); + + if (!context) { + return undefined; + } + + return ( + <> + {serverComponent && !serverComponent.hidden && ( + + context={context} + component={serverComponent} + idx={startIdx} + props={{ orientation: "horizontal" }} + /> + )} + {databaseComponent && !databaseComponent.hidden && ( + + context={context} + component={databaseComponent} + idx={startIdx + 1} + props={{ orientation: "horizontal" }} + /> + )} + + ); +}; diff --git a/src/reactviews/pages/PublishProject/components/PublishProfile.tsx b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx similarity index 100% rename from src/reactviews/pages/PublishProject/components/PublishProfile.tsx rename to src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx diff --git a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx new file mode 100644 index 0000000000..029fa3a8be --- /dev/null +++ b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { useContext } from "react"; +import { PublishProjectContext } from "../publishProjectStateProvider"; +import { usePublishDialogSelector } from "../publishDialogSelector"; +import { FormField } from "../../../common/forms/form.component"; +import { + IPublishForm, + PublishDialogState, + PublishDialogFormItemSpec, +} from "../../../../sharedInterfaces/publishDialog"; +import { FormContextProps } from "../../../../sharedInterfaces/form"; + +// Context type (mirrors existing usage in page) +type PublishFormContext = FormContextProps< + IPublishForm, + PublishDialogState, + PublishDialogFormItemSpec +> & { + publishNow: () => void; + generatePublishScript: () => void; + selectPublishProfile: () => void; + savePublishProfile: (profileName: string) => void; +}; + +export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { + const context = useContext(PublishProjectContext) as PublishFormContext | undefined; + const component = usePublishDialogSelector((s) => s.formComponents.publishTarget, Object.is); + + if (!context || !component || component.hidden) { + return undefined; + } + + return ( + + context={context} + component={component} + idx={idx} + props={{ orientation: "horizontal" }} + /> + ); +}; diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index 0f9ca230fe..4fd33a7a41 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -5,18 +5,19 @@ import { useContext } from "react"; import { Button, makeStyles } from "@fluentui/react-components"; -import { FormField, useFormStyles } from "../../common/forms/form.component"; +import { useFormStyles } from "../../common/forms/form.component"; import { PublishProjectStateProvider, PublishProjectContext } from "./publishProjectStateProvider"; -import { useVscodeSelector } from "../../common/useVscodeSelector"; +import { usePublishDialogSelector } from "./publishDialogSelector"; import { LocConstants } from "../../common/locConstants"; import { IPublishForm, PublishDialogFormItemSpec, PublishDialogState, - PublishDialogReducers, } from "../../../sharedInterfaces/publishDialog"; import { FormContextProps } from "../../../sharedInterfaces/form"; -import { PublishProfileField } from "./components/PublishProfile"; +import { PublishProfileField } from "./components/PublishProfileSection"; +import { PublishTargetSection } from "./components/PublishTargetSection"; +import { ConnectionSection } from "./components/ConnectionSection"; const useStyles = makeStyles({ root: { padding: "12px" }, @@ -50,64 +51,23 @@ function PublishProjectInner() { const loc = LocConstants.getInstance().publishProject; const context = useContext(PublishProjectContext) as PublishFormContext | undefined; // Select pieces of state needed for this component - const formComponents = useVscodeSelector< - PublishDialogState, - PublishDialogReducers, - PublishDialogState["formComponents"] | undefined - >((s) => s.formComponents, Object.is); - const formState = useVscodeSelector< - PublishDialogState, - PublishDialogReducers, - PublishDialogState["formState"] | undefined - >((s) => s.formState, Object.is); + const formComponents = usePublishDialogSelector((s) => s.formComponents, Object.is); + const formState = usePublishDialogSelector((s) => s.formState, Object.is); const loading = !context || !formComponents || !formState; if (loading) { return
Loading...
; } - // Static list of main publish dialog options - const mainOptions: (keyof IPublishForm)[] = [ - "publishTarget", - "profileName", - "serverName", - "databaseName", - ]; + // Static ordering now expressed via explicit section components. return ( e.preventDefault()}>
- {mainOptions.map((optionName, idx) => { - if (!optionName) { - return undefined; - } - - if ((optionName as string) === "profileName") { - return ; - } - - const component = formComponents[ - optionName as keyof IPublishForm - ] as PublishDialogFormItemSpec; - if (!component || component.hidden === true) { - return undefined; - } - return ( - - key={String(optionName)} - context={context} - component={component} - idx={idx} - props={{ orientation: "horizontal" }} - /> - ); - })} + + +
-
@@ -85,10 +135,10 @@ function PublishProjectInner() { ); } -export default function PublishProjectPageWrapper() { +export default function PublishProjectPage() { return ( - + ); } diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 89507f5711..78cce37200 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -55,9 +55,10 @@ suite("PublishProjectWebViewController Tests", () => { expect(controller.state.projectFilePath).to.equal(projectPath); expect(controller.state.formState.databaseName).to.equal("MySampleProject"); - // Allow async initializeDialog() to finish populating formComponents - await new Promise((r) => setTimeout(r, 0)); + // Wait for async initializeDialog() to finish populating formComponents + await controller.initialized.promise; + // Form components should be initialized after async initialization const components = controller.state.formComponents; // Basic fields expected from generatePublishFormComponents() expect(components.profileName, "profileName component should exist").to.exist; From db5f69148027a210a478a7ee4b72cb458c34f691 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 23 Sep 2025 00:28:06 -0500 Subject: [PATCH 17/94] adding tests that covers the target field --- src/constants/constants.ts | 16 +++++-- .../publishProjectWebViewController.ts | 47 ++++++++++++------- .../components/ConnectionSection.tsx | 8 +++- .../components/PublishTargetSection.tsx | 47 ++++++++++--------- .../pages/PublishProject/publishProject.tsx | 2 - test/unit/publishProjectDialog.test.ts | 18 ++++--- 6 files changed, 83 insertions(+), 55 deletions(-) diff --git a/src/constants/constants.ts b/src/constants/constants.ts index c624c6818a..f5daafe3bc 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -291,7 +291,7 @@ export const sqlServerDockerRegistry = "mcr.microsoft.com"; export const sqlServerDockerRepository = "mssql/server"; export const sqlServerEulaLink = "https://aka.ms/mssql-container-license"; export const dockerImageDefaultTag = "latest"; -export const AzureSqlDbFullDockerImageName = "Azure SQL Database (Edge emulation)"; // placeholder label +export const AzureSqlDbFullDockerImageName = "Azure SQL Database (Edge emulation)"; export const SqlServerDockerImageName = "SQL Server"; export const MAX_PORT_NUMBER = 65535; export const SqlServerName = "SQL server"; @@ -300,10 +300,20 @@ export const DefaultSqlPortNumber = "1433"; export const RequiredFieldMessage = "Required"; export const DefaultAdminUsername = "sa"; export const LicenseAcceptanceMessage = "You must accept the license"; - -// Publish Project Target Constants export const PublishTargets = { EXISTING_SERVER: "existingServer" as const, LOCAL_CONTAINER: "localContainer" as const, } as const; export type PublishTargetType = (typeof PublishTargets)[keyof typeof PublishTargets]; +export const PublishFormFields = { + ProfileName: "profileName", + ServerName: "serverName", + DatabaseName: "databaseName", + PublishTarget: "publishTarget", + SqlCmdVariables: "sqlCmdVariables", + ContainerPort: "containerPort", + ContainerAdminPassword: "containerAdminPassword", + ContainerAdminPasswordConfirm: "containerAdminPasswordConfirm", + ContainerImageTag: "containerImageTag", + AcceptContainerLicense: "acceptContainerLicense", +} as const; diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 02d0987861..505a760cf7 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -145,7 +145,7 @@ export class PublishProjectWebViewController extends FormWebviewController< formState: newFormState, projectFilePath: changes.projectFilePath ?? state.projectFilePath, }; - await this.updateItemVisibility(); + await this.updateItemVisibility(newState); return newState; }); @@ -183,33 +183,46 @@ export class PublishProjectWebViewController extends FormWebviewController< protected getActiveFormComponents(state: PublishDialogState): (keyof IPublishForm)[] { const activeComponents: (keyof IPublishForm)[] = [ - "publishTarget", - "profileName", - "serverName", - "databaseName", - ]; + constants.PublishFormFields.PublishTarget, + constants.PublishFormFields.ProfileName, + constants.PublishFormFields.ServerName, + constants.PublishFormFields.DatabaseName, + ] as (keyof IPublishForm)[]; if (state.formState.publishTarget === constants.PublishTargets.LOCAL_CONTAINER) { activeComponents.push( - "containerPort", - "containerAdminPassword", - "containerAdminPasswordConfirm", - "containerImageTag", - "acceptContainerLicense", + constants.PublishFormFields.ContainerPort, + constants.PublishFormFields.ContainerAdminPassword, + constants.PublishFormFields.ContainerAdminPasswordConfirm, + constants.PublishFormFields.ContainerImageTag, + constants.PublishFormFields.AcceptContainerLicense, ); } return activeComponents; } - public async updateItemVisibility(): Promise { - const hidden: (keyof IPublishForm)[] = []; - if (this.state.formState?.publishTarget === constants.PublishTargets.LOCAL_CONTAINER) { - hidden.push("serverName"); + public async updateItemVisibility(state?: PublishDialogState): Promise { + const currentState = state || this.state; + const target = currentState.formState?.publishTarget; + const hidden: string[] = []; + + if (target === constants.PublishTargets.LOCAL_CONTAINER) { + // Hide server-specific fields when targeting local container + hidden.push(constants.PublishFormFields.ServerName); + } else if (target === constants.PublishTargets.EXISTING_SERVER) { + // Hide container-specific fields when targeting existing server + hidden.push( + constants.PublishFormFields.ContainerPort, + constants.PublishFormFields.ContainerAdminPassword, + constants.PublishFormFields.ContainerAdminPasswordConfirm, + constants.PublishFormFields.ContainerImageTag, + constants.PublishFormFields.AcceptContainerLicense, + ); } - for (const component of Object.values(this.state.formComponents)) { - component.hidden = hidden.includes(component.propertyName as keyof IPublishForm); + for (const component of Object.values(currentState.formComponents)) { + component.hidden = hidden.includes(component.propertyName); } } } diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx index fd24b8313c..9c31ebb00e 100644 --- a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -13,13 +13,17 @@ import { PublishDialogFormItemSpec, } from "../../../../sharedInterfaces/publishDialog"; import { PublishFormContext } from "../types"; +import * as constants from "../../../../constants/constants"; export const ConnectionSection: React.FC<{ startIdx: number }> = ({ startIdx }) => { const context = useContext(PublishProjectContext) as PublishFormContext | undefined; - const serverComponent = usePublishDialogSelector((s) => s.formComponents.serverName, Object.is); + const serverComponent = usePublishDialogSelector( + (s) => s.formComponents[constants.PublishFormFields.ServerName], + Object.is, + ); const databaseComponent = usePublishDialogSelector( - (s) => s.formComponents.databaseName, + (s) => s.formComponents[constants.PublishFormFields.DatabaseName], Object.is, ); diff --git a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx index e80b0b1d31..e3ba71ae7d 100644 --- a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { useContext, useEffect } from "react"; -import { makeStyles, Checkbox, tokens } from "@fluentui/react-components"; +import { makeStyles, Checkbox, tokens, CheckboxOnChangeData } from "@fluentui/react-components"; import { PublishProjectContext } from "../publishProjectStateProvider"; import { usePublishDialogSelector } from "../publishDialogSelector"; import { @@ -43,12 +43,12 @@ const useStyles = makeStyles({ }); const containerFieldOrder: (keyof IPublishForm)[] = [ - "containerPort", - "containerAdminPassword", - "containerAdminPasswordConfirm", - "containerImageTag", - "acceptContainerLicense", -]; + constants.PublishFormFields.ContainerPort, + constants.PublishFormFields.ContainerAdminPassword, + constants.PublishFormFields.ContainerAdminPasswordConfirm, + constants.PublishFormFields.ContainerImageTag, + constants.PublishFormFields.AcceptContainerLicense, +] as (keyof IPublishForm)[]; export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { const classes = useStyles(); @@ -56,13 +56,16 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { // Select just the publishTarget FormItemSpec const targetSpec = usePublishDialogSelector( - (s) => s.formComponents.publishTarget as PublishDialogFormItemSpec | undefined, + (s) => + s.formComponents[constants.PublishFormFields.PublishTarget] as + | PublishDialogFormItemSpec + | undefined, Object.is, ); // Select the current target value const publishTargetValue = usePublishDialogSelector( - (s) => s.formState.publishTarget, + (s) => s.formState[constants.PublishFormFields.PublishTarget], (a, b) => a === b, ); @@ -80,7 +83,7 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { // Set default port once when entering container mode if (!context.state.formState.containerPort) { context.formAction({ - propertyName: "containerPort", + propertyName: constants.PublishFormFields.ContainerPort, isAction: false, value: constants.DefaultSqlPortNumber, }); @@ -89,7 +92,7 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { // Set default image tag if none selected if (!context.state.formState.containerImageTag) { context.formAction({ - propertyName: "containerImageTag", + propertyName: constants.PublishFormFields.ContainerImageTag, isAction: false, value: constants.dockerImageDefaultTag, }); @@ -122,7 +125,7 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { } // License checkbox special rendering - if (name === "acceptContainerLicense") { + if (name === constants.PublishFormFields.AcceptContainerLicense) { const isChecked = (context.state.formState[ comp.propertyName as keyof IPublishForm @@ -147,15 +150,17 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { label={licenseLabel} required={true} checked={isChecked} - onChange={ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_: any, data: { checked?: boolean }) => - context.formAction({ - propertyName: comp.propertyName, - isAction: false, - value: data.checked ?? false, - }) - } + onChange={( + _: React.ChangeEvent, + data: CheckboxOnChangeData, + ) => { + const isChecked = data.checked === true; + context.formAction({ + propertyName: comp.propertyName, + isAction: false, + value: isChecked, + }); + }} style={{ alignItems: "flex-start" }} /> {isError && errorMessage && ( diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index 62885bfc8e..d5f9b1df62 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -77,8 +77,6 @@ function PublishProjectDialog() { return
Loading...
; } - // Static ordering now expressed via explicit section components. - return ( e.preventDefault()}>
diff --git a/test/unit/publishProjectDialog.test.ts b/test/unit/publishProjectDialog.test.ts index 44331250bf..343b1ae943 100644 --- a/test/unit/publishProjectDialog.test.ts +++ b/test/unit/publishProjectDialog.test.ts @@ -53,9 +53,11 @@ suite("PublishProjectWebViewController", () => { projectPath, ); + // Wait for async initialization to complete + await controller.initialized.promise; + // Access internal reducer handlers map to invoke reducers directly - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const reducerHandlers = (controller as any)._reducerHandlers as Map; + const reducerHandlers = controller["_reducerHandlers"] as Map; const setPublishValues = reducerHandlers.get("setPublishValues"); expect(setPublishValues, "setPublishValues reducer should be registered").to.exist; @@ -65,9 +67,6 @@ suite("PublishProjectWebViewController", () => { }); controller.updateState(newState); - // Wait for async form component generation - await new Promise((r) => setTimeout(r, 0)); - // Act - Test updating container port newState = await setPublishValues(controller.state, { containerPort: "1434", @@ -134,9 +133,11 @@ suite("PublishProjectWebViewController", () => { projectPath, ); + // Wait for async initialization to complete + await controller.initialized.promise; + // Access internal reducer handlers map to invoke reducers directly - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const reducerHandlers = (controller as any)._reducerHandlers as Map; + const reducerHandlers = controller["_reducerHandlers"] as Map; const setPublishValues = reducerHandlers.get("setPublishValues"); expect(setPublishValues, "setPublishValues reducer should be registered").to.exist; @@ -146,9 +147,6 @@ suite("PublishProjectWebViewController", () => { }); controller.updateState(newState); - // Wait for async form component generation - await new Promise((r) => setTimeout(r, 0)); - // Assert - Verify container components are hidden when target is existingServer expect(controller.state.formComponents.containerPort?.hidden).to.be.true; expect(controller.state.formComponents.containerAdminPassword?.hidden).to.be.true; From 561fda8d21a400668c87dc3a83344f98c4996f6b Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 23 Sep 2025 12:26:23 -0500 Subject: [PATCH 18/94] copilot review updates --- src/publishProject/dockerUtils.ts | 88 +++++++++++++++++-- .../components/PublishTargetSection.tsx | 77 +++++++++------- src/sharedInterfaces/publishDialog.ts | 2 +- 3 files changed, 129 insertions(+), 38 deletions(-) diff --git a/src/publishProject/dockerUtils.ts b/src/publishProject/dockerUtils.ts index c6cc889c86..d130564f8f 100644 --- a/src/publishProject/dockerUtils.ts +++ b/src/publishProject/dockerUtils.ts @@ -173,6 +173,32 @@ export function isValidSqlAdminPassword(password: string, userName = "sa"): bool return hasUpper + hasLower + hasDigit + hasSymbol >= 3; } +/** + * Parses license text with HTML link and returns safe components for rendering + */ +export function parseLicenseText(licenseText: string) { + const linkMatch = licenseText.match(/]*>([^<]+)<\/a>/); + + if (linkMatch) { + const linkUrl = linkMatch[1]; + const linkText = linkMatch[2]; + const parts = licenseText.split(linkMatch[0]); + + return { + hasLink: true, + beforeText: parts[0] || "", + linkText, + linkUrl, + afterText: parts[1] || "", + }; + } + + return { + hasLink: false, + plainText: licenseText, + }; +} + /** * Loads Docker tags for a given target version and updates form component options */ @@ -185,15 +211,67 @@ export async function loadDockerTags( let tags: string[] = []; try { - const resp = await fetch(baseImage.tagsUrl, { method: "GET" }); - if (resp.ok) { + // Security: Validate URL is from trusted Microsoft registry + const url = new URL(baseImage.tagsUrl); + if (!url.hostname.endsWith(".microsoft.com") && url.hostname !== "mcr.microsoft.com") { + console.warn("Untrusted registry URL blocked:", baseImage.tagsUrl); + return; + } + + // Create AbortController for timeout control + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + try { + const resp = await fetch(baseImage.tagsUrl, { + method: "GET", + signal: controller.signal, + headers: { + Accept: "application/json", + "User-Agent": "vscode-mssql-extension", + }, + // Security: Prevent credentials from being sent + credentials: "omit", + // Security: Follow redirects only to same origin + redirect: "follow", + }); + + clearTimeout(timeoutId); + + if (!resp.ok) { + console.warn(`Failed to fetch Docker tags: ${resp.status} ${resp.statusText}`); + return; + } + + // Security: Check content type + const contentType = resp.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + console.warn("Invalid content type for Docker tags response:", contentType); + return; + } + const json = await resp.json(); if (json?.tags && Array.isArray(json.tags)) { - tags = json.tags as string[]; + // Security: Validate tag format to prevent injection + tags = (json.tags as string[]).filter( + (tag) => + typeof tag === "string" && + /^[a-zA-Z0-9._-]+$/.test(tag) && + tag.length <= 128, + ); + } + } catch (fetchError: unknown) { + clearTimeout(timeoutId); + if (fetchError instanceof Error && fetchError.name === "AbortError") { + console.warn("Docker tags request timed out"); + } else { + console.warn("Network error fetching Docker tags:", fetchError); } + return; } - } catch { - // ignore network errors; leave tags empty + } catch (urlError: unknown) { + console.warn("Invalid Docker tags URL:", urlError); + return; } const imageTags = filterAndSortTags(tags, baseImage, targetVersion, true); diff --git a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx index e3ba71ae7d..71426358ff 100644 --- a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx @@ -14,6 +14,7 @@ import { } from "../../../../sharedInterfaces/publishDialog"; import { FormField } from "../../../common/forms/form.component"; import { PublishFormContext } from "../types"; +import { parseLicenseText } from "../../../../publishProject/dockerUtils"; import * as constants from "../../../../constants/constants"; const useStyles = makeStyles({ @@ -40,6 +41,20 @@ const useStyles = makeStyles({ licenseLabel: { lineHeight: "1.3", }, + licenseContainer: { + display: "flex", + alignItems: "center", + gap: "8px", + }, + licenseLink: { + textDecoration: "underline", + color: "var(--vscode-textLink-foreground)", + }, + licenseError: { + color: tokens.colorStatusDangerForeground1, + fontSize: "12px", + marginLeft: "24px", + }, }); const containerFieldOrder: (keyof IPublishForm)[] = [ @@ -131,46 +146,44 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { comp.propertyName as keyof IPublishForm ] as boolean) ?? false; - // License checkbox is required - show error only if validation has been attempted const validation = comp.validation; const isError = validation !== undefined && !validation.isValid; const errorMessage = isError ? validation.validationMessage : undefined; - const licenseLabel = ( - - ); + const licenseInfo = parseLicenseText(comp.label || ""); return (
- , - data: CheckboxOnChangeData, - ) => { - const isChecked = data.checked === true; - context.formAction({ - propertyName: comp.propertyName, - isAction: false, - value: isChecked, - }); - }} - style={{ alignItems: "flex-start" }} - /> - {isError && errorMessage && ( - - {errorMessage} +
+ , + data: CheckboxOnChangeData, + ) => { + context.formAction({ + propertyName: comp.propertyName, + isAction: false, + value: data.checked === true, + }); + }} + /> + + {licenseInfo.beforeText} + + {licenseInfo.linkText} + + {licenseInfo.afterText} +
+ {isError && errorMessage && ( + {errorMessage} )}
); diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index c4235d6247..e0d7fc5116 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -59,7 +59,7 @@ export interface PublishDialogReducers extends FormReducers { profileName?: string; serverName?: string; databaseName?: string; - publishTarget?: "existingServer" | "localContainer"; + publishTarget?: constants.PublishTargetType; sqlCmdVariables?: { [key: string]: string }; containerPort?: string; containerAdminPassword?: string; From 73d1926ed8f92221fe2b232e2011039282148a91 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 23 Sep 2025 14:10:40 -0500 Subject: [PATCH 19/94] little refactoring --- src/publishProject/dockerUtils.ts | 41 ----------------- src/publishProject/formComponentHelpers.ts | 45 ++++++++++--------- src/publishProject/projectUtils.ts | 41 +++++++++++++++++ .../publishProjectWebViewController.ts | 5 ++- .../components/ConnectionSection.tsx | 6 +-- .../pages/PublishProject/publishProject.tsx | 2 +- src/sharedInterfaces/publishDialog.ts | 11 +---- 7 files changed, 74 insertions(+), 77 deletions(-) diff --git a/src/publishProject/dockerUtils.ts b/src/publishProject/dockerUtils.ts index d130564f8f..053d27f4c8 100644 --- a/src/publishProject/dockerUtils.ts +++ b/src/publishProject/dockerUtils.ts @@ -132,47 +132,6 @@ export function getDockerBaseImage(target: string, azureTargetVersion?: string): }; } -/* - * Validates the SQL Server port number. - */ -export function validateSqlServerPortNumber(port: string | number | undefined): boolean { - if (port === undefined) { - return false; - } - const str = String(port).trim(); - if (str.length === 0) { - return false; - } - // Must be all digits - if (!/^[0-9]+$/.test(str)) { - return false; - } - const n = Number(str); - return n >= 1 && n <= constants.MAX_PORT_NUMBER; -} - -/** - * Returns true if password meets SQL complexity (length 8-128, does not contain login name, - * and contains at least 3 of 4 categories: upper, lower, digit, symbol). - */ -export function isValidSqlAdminPassword(password: string, userName = "sa"): boolean { - if (!password) { - return false; - } - const containsUserName = !!userName && password.toUpperCase().includes(userName.toUpperCase()); - if (containsUserName) { - return false; - } - if (password.length < 8 || password.length > 128) { - return false; - } - const hasUpper = /[A-Z]/.test(password) ? 1 : 0; - const hasLower = /[a-z]/.test(password) ? 1 : 0; - const hasDigit = /\d/.test(password) ? 1 : 0; - const hasSymbol = /\W/.test(password) ? 1 : 0; - return hasUpper + hasLower + hasDigit + hasSymbol >= 3; -} - /** * Parses license text with HTML link and returns safe components for rendering */ diff --git a/src/publishProject/formComponentHelpers.ts b/src/publishProject/formComponentHelpers.ts index 914b138c64..c446dd65a5 100644 --- a/src/publishProject/formComponentHelpers.ts +++ b/src/publishProject/formComponentHelpers.ts @@ -5,14 +5,17 @@ import * as constants from "../constants/constants"; import { FormItemType } from "../sharedInterfaces/form"; +import { PublishProject as Loc } from "../constants/locConstants"; import { IPublishForm, PublishDialogFormItemSpec, PublishDialogState, } from "../sharedInterfaces/publishDialog"; -import { PublishProject as Loc } from "../constants/locConstants"; -import { validateSqlServerPortNumber, isValidSqlAdminPassword } from "./dockerUtils"; -import { getPublishServerName } from "./projectUtils"; +import { + getPublishServerName, + validateSqlServerPortNumber, + isValidSqlAdminPassword, +} from "./projectUtils"; /** * Generate publish form components. Kept async for future extensibility @@ -22,20 +25,20 @@ export async function generatePublishFormComponents(): Promise< Record > { const components: Record = { - profileName: { - propertyName: "profileName", + [constants.PublishFormFields.ProfileName]: { + propertyName: constants.PublishFormFields.ProfileName, label: Loc.ProfileLabel, required: false, type: FormItemType.Input, }, - serverName: { - propertyName: "serverName", + [constants.PublishFormFields.ServerName]: { + propertyName: constants.PublishFormFields.ServerName, label: Loc.ServerLabel, required: false, type: FormItemType.Input, }, - databaseName: { - propertyName: "databaseName", + [constants.PublishFormFields.DatabaseName]: { + propertyName: constants.PublishFormFields.DatabaseName, label: Loc.DatabaseLabel, required: true, type: FormItemType.Input, @@ -44,8 +47,8 @@ export async function generatePublishFormComponents(): Promise< return { isValid, validationMessage: isValid ? "" : Loc.DatabaseRequiredMessage }; }, }, - publishTarget: { - propertyName: "publishTarget", + [constants.PublishFormFields.PublishTarget]: { + propertyName: constants.PublishFormFields.PublishTarget, label: Loc.PublishTargetLabel, required: true, type: FormItemType.Dropdown, @@ -60,8 +63,8 @@ export async function generatePublishFormComponents(): Promise< }, ], }, - containerPort: { - propertyName: "containerPort", + [constants.PublishFormFields.ContainerPort]: { + propertyName: constants.PublishFormFields.ContainerPort, label: Loc.SqlServerPortNumber, required: true, type: FormItemType.Input, @@ -74,8 +77,8 @@ export async function generatePublishFormComponents(): Promise< }; }, }, - containerAdminPassword: { - propertyName: "containerAdminPassword", + [constants.PublishFormFields.ContainerAdminPassword]: { + propertyName: constants.PublishFormFields.ContainerAdminPassword, label: Loc.SqlServerAdminPassword, required: true, type: FormItemType.Password, @@ -92,8 +95,8 @@ export async function generatePublishFormComponents(): Promise< }; }, }, - containerAdminPasswordConfirm: { - propertyName: "containerAdminPasswordConfirm", + [constants.PublishFormFields.ContainerAdminPasswordConfirm]: { + propertyName: constants.PublishFormFields.ContainerAdminPasswordConfirm, label: Loc.SqlServerAdminPasswordConfirm, required: true, type: FormItemType.Password, @@ -111,8 +114,8 @@ export async function generatePublishFormComponents(): Promise< }; }, }, - containerImageTag: { - propertyName: "containerImageTag", + [constants.PublishFormFields.ContainerImageTag]: { + propertyName: constants.PublishFormFields.ContainerImageTag, label: Loc.SqlServerImageTag, required: true, type: FormItemType.Dropdown, @@ -122,8 +125,8 @@ export async function generatePublishFormComponents(): Promise< return { isValid: !!v, validationMessage: v ? "" : constants.RequiredFieldMessage }; }, }, - acceptContainerLicense: { - propertyName: "acceptContainerLicense", + [constants.PublishFormFields.AcceptContainerLicense]: { + propertyName: constants.PublishFormFields.AcceptContainerLicense, label: Loc.UserLicenseAgreement( "https://github.com/microsoft/containerregistry/blob/main/legal/Container-Images-Legal-Notice.md", ), diff --git a/src/publishProject/projectUtils.ts b/src/publishProject/projectUtils.ts index 5f26aebbce..03a88f186d 100644 --- a/src/publishProject/projectUtils.ts +++ b/src/publishProject/projectUtils.ts @@ -173,3 +173,44 @@ export function validatePublishForm(formState: IPublishForm): boolean { return false; } + +/* + * Validates the SQL Server port number. + */ +export function validateSqlServerPortNumber(port: string | number | undefined): boolean { + if (port === undefined) { + return false; + } + const str = String(port).trim(); + if (str.length === 0) { + return false; + } + // Must be all digits + if (!/^[0-9]+$/.test(str)) { + return false; + } + const n = Number(str); + return n >= 1 && n <= constants.MAX_PORT_NUMBER; +} + +/** + * Returns true if password meets SQL complexity (length 8-128, does not contain login name, + * and contains at least 3 of 4 categories: upper, lower, digit, symbol). + */ +export function isValidSqlAdminPassword(password: string, userName = "sa"): boolean { + if (!password) { + return false; + } + const containsUserName = !!userName && password.toUpperCase().includes(userName.toUpperCase()); + if (containsUserName) { + return false; + } + if (password.length < 8 || password.length > 128) { + return false; + } + const hasUpper = /[A-Z]/.test(password) ? 1 : 0; + const hasLower = /[a-z]/.test(password) ? 1 : 0; + const hasDigit = /\d/.test(password) ? 1 : 0; + const hasSymbol = /\W/.test(password) ? 1 : 0; + return hasUpper + hasLower + hasDigit + hasSymbol >= 3; +} diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 505a760cf7..02a975e4e7 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -115,7 +115,10 @@ export class PublishProjectWebViewController extends FormWebviewController< // Fetch Docker tags for the container image dropdown if (props.targetVersion) { - const tagComponent = this.state.formComponents["containerImageTag"]; + const tagComponent = + this.state.formComponents[ + constants.PublishFormFields.ContainerImageTag + ]; if (tagComponent) { await loadDockerTags( props.targetVersion, diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx index 9c31ebb00e..49a78d4424 100644 --- a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -15,7 +15,7 @@ import { import { PublishFormContext } from "../types"; import * as constants from "../../../../constants/constants"; -export const ConnectionSection: React.FC<{ startIdx: number }> = ({ startIdx }) => { +export const ConnectionSection: React.FC<{ idx: number }> = ({ idx }) => { const context = useContext(PublishProjectContext) as PublishFormContext | undefined; const serverComponent = usePublishDialogSelector( @@ -42,7 +42,7 @@ export const ConnectionSection: React.FC<{ startIdx: number }> = ({ startIdx }) > context={context} component={serverComponent} - idx={startIdx} + idx={idx} props={{ orientation: "horizontal" }} /> )} @@ -55,7 +55,7 @@ export const ConnectionSection: React.FC<{ startIdx: number }> = ({ startIdx }) > context={context} component={databaseComponent} - idx={startIdx + 1} + idx={idx + 1} props={{ orientation: "horizontal" }} /> )} diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index d5f9b1df62..0c6a9dd3e5 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -83,7 +83,7 @@ function PublishProjectDialog() {
- +
diff --git a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx index 09cf8fc499..f83cade6cb 100644 --- a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx +++ b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx @@ -5,45 +5,34 @@ import { createContext, useMemo } from "react"; import { useVscodeWebview2 } from "../../common/vscodeWebviewProvider2"; +import { getCoreRPCs2 } from "../../common/utils"; import { WebviewRpc } from "../../common/rpc"; import { PublishDialogReducers, PublishDialogState, IPublishForm, + PublishProjectProvider, } from "../../../sharedInterfaces/publishDialog"; +import { FormEvent } from "../../../sharedInterfaces/form"; +import { + LoggerLevel, + WebviewTelemetryActionEvent, + WebviewTelemetryErrorEvent, +} from "../../../sharedInterfaces/webview"; -export interface PublishProjectContextValue { - // Use inner state directly for form system generics - state?: PublishDialogState; // snapshot accessor - formAction: (event: PublishFormActionEvent) => void; - publishNow: (payload?: PublishNowPayload) => void; - generatePublishScript: () => void; - selectPublishProfile: () => void; - savePublishProfile: (profileName: string) => void; - setPublishValues: ( - values: Partial & { projectFilePath?: string }, - ) => void; +export interface PublishProjectContextProps extends PublishProjectProvider { + readonly state?: PublishDialogState; + log(message: string, level?: LoggerLevel): void; + sendActionEvent(event: WebviewTelemetryActionEvent): void; + sendErrorEvent(event: WebviewTelemetryErrorEvent): void; + /** Advanced escape hatch; prefer using typed provider methods */ extensionRpc: WebviewRpc; } -// Event payload coming from shared FormField components -export interface PublishFormActionEvent { - propertyName: keyof IPublishForm; - value: string | boolean; - isAction: boolean; // true when triggered by an action button on the field - updateValidation?: boolean; // optional flag to force validation -} - // Optional payload for publishNow future expansion -export interface PublishNowPayload { - projectFilePath?: string; - databaseName?: string; - connectionUri?: string; - sqlCmdVariables?: { [key: string]: string }; - publishProfilePath?: string; -} +export type PublishNowPayload = Parameters[0]; -export const PublishProjectContext = createContext( +export const PublishProjectContext = createContext( undefined, ); @@ -55,7 +44,7 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } PublishDialogReducers >(); - const value = useMemo( + const value = useMemo( () => ({ get state() { const inner = getSnapshot(); // inner PublishDialogState @@ -64,7 +53,8 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } } return inner; }, - formAction: (event: PublishFormActionEvent) => + ...getCoreRPCs2(extensionRpc), + formAction: (event: FormEvent) => extensionRpc.action("formAction", { event }), publishNow: (payload?: PublishNowPayload) => extensionRpc.action("publishNow", payload ?? {}), @@ -72,9 +62,7 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } selectPublishProfile: () => extensionRpc.action("selectPublishProfile"), savePublishProfile: (profileName: string) => extensionRpc.action("savePublishProfile", { profileName }), - setPublishValues: ( - values: Partial & { projectFilePath?: string }, - ) => extensionRpc.action("setPublishValues", values), + setPublishValues: (values) => extensionRpc.action("setPublishValues", values), extensionRpc, }), [extensionRpc, getSnapshot], diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 2b34a397d0..bdc94098d5 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { FormItemSpec, FormState, FormReducers } from "./form"; +import { FormItemSpec, FormState, FormReducers, FormEvent } from "./form"; import { RequestType } from "vscode-jsonrpc/browser"; /** @@ -33,9 +33,8 @@ export interface PublishDialogState */ export interface PublishDialogFormItemSpec extends FormItemSpec { - isAdvancedOption?: boolean; - optionCategory?: string; - optionCategoryLabel?: string; + // (Removed advanced option metadata: was isAdvancedOption/optionCategory/optionCategoryLabel) + // Reintroduce when the Publish dialog gains an "Advanced" section with grouped fields. } /** @@ -64,6 +63,38 @@ export interface PublishDialogReducers extends FormReducers { savePublishProfile: { profileName: string }; } +/** + * Public operations + form dispatch surface for the Publish Project webview. + * React context a stable, typed contract while keeping implementation details (raw RPC naming, snapshot plumbing) encapsulated. + */ +export interface PublishProjectProvider { + /** Dispatch a single field value change or field-level action */ + formAction(event: FormEvent): void; + /** Execute an immediate publish using current (or overridden) form values */ + publishNow(payload?: { + projectFilePath?: string; + databaseName?: string; + connectionUri?: string; + sqlCmdVariables?: { [key: string]: string }; + publishProfilePath?: string; + }): void; + /** Generate (but do not execute) a publish script */ + generatePublishScript(): void; + /** Choose a publish profile file and apply (may partially override form state) */ + selectPublishProfile(): void; + /** Persist current form state as a named profile */ + savePublishProfile(profileName: string): void; + /** Bulk set form values (e.g., after loading a profile) */ + setPublishValues(values: { + profileName?: string; + serverName?: string; + databaseName?: string; + publishTarget?: "existingServer" | "localContainer"; + sqlCmdVariables?: { [key: string]: string }; + projectFilePath?: string; + }): void; +} + /** * Example request pattern retained for future preview scenarios. */ diff --git a/typings/vscode-mssql.d.ts b/typings/vscode-mssql.d.ts index 1af5338a98..ce3f36baa8 100644 --- a/typings/vscode-mssql.d.ts +++ b/typings/vscode-mssql.d.ts @@ -475,15 +475,6 @@ declare module "vscode-mssql" { cancel(operationId: string): Thenable; } - export interface IPublishDatabaseProjectService { - publishProject( - operationId: string, - targetServerName: string, - targetDatabaseName: string, - taskExecutionMode: TaskExecutionMode, - ): Thenable; - } - export interface IDacFxService { exportBacpac( databaseName: string, From 4929b343a83e207f57b5a7fb76505ee7b5e04f74 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 24 Sep 2025 01:28:29 -0500 Subject: [PATCH 22/94] updating LOC and fixing test --- localization/l10n/bundle.l10n.json | 1 + localization/xliff/vscode-mssql.xlf | 3 + .../publishProjectWebViewController.test.ts | 69 +++++++++++++++---- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 505954df78..0921aa7082 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1654,6 +1654,7 @@ "Publish Profile": "Publish Profile", "Select or enter a publish profile": "Select or enter a publish profile", "Database name is required": "Database name is required", + "SQLCMD Variables": "SQLCMD Variables", "Publish Target": "Publish Target", "Existing SQL server": "Existing SQL server", "Local development container": "Local development container", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 43ee4a7204..7b2210f635 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -2529,6 +2529,9 @@ SQL database in Fabric (Preview) + + SQLCMD Variables + SVG diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 78cce37200..060dedb0c5 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as TypeMoq from "typemoq"; import * as vscode from "vscode"; import { expect } from "chai"; import * as sinon from "sinon"; @@ -13,16 +12,12 @@ import { PublishProjectWebViewController } from "../../src/publishProject/publis suite("PublishProjectWebViewController Tests", () => { let sandbox: sinon.SinonSandbox; - let mockContext: TypeMoq.IMock; - let mockVscodeWrapper: TypeMoq.IMock; + let contextStub: vscode.ExtensionContext; + let vscodeWrapperStub: VscodeWrapper; setup(() => { sandbox = sinon.createSandbox(); - mockContext = TypeMoq.Mock.ofType(); - mockContext.setup((c) => c.extensionUri).returns(() => vscode.Uri.parse("file://fakePath")); - mockContext.setup((c) => c.extensionPath).returns(() => "fakePath"); - mockContext.setup((c) => c.subscriptions).returns(() => []); const globalState = { get: ((_key: string, defaultValue?: T) => defaultValue) as { (key: string): T | undefined; @@ -32,11 +27,59 @@ suite("PublishProjectWebViewController Tests", () => { keys: () => [] as readonly string[], setKeysForSync: (_keys: readonly string[]) => undefined, } as unknown as vscode.Memento & { setKeysForSync(keys: readonly string[]): void }; - mockContext.setup((c) => c.globalState).returns(() => globalState); - mockVscodeWrapper = TypeMoq.Mock.ofType(VscodeWrapper); - const outputChannel = TypeMoq.Mock.ofType(); - mockVscodeWrapper.setup((v) => v.outputChannel).returns(() => outputChannel.object); + const rawContext = { + extensionUri: vscode.Uri.parse("file://ProjectPath"), + extensionPath: "ProjectPath", + subscriptions: [], + globalState, + workspaceState: globalState, + storagePath: undefined, + storageUri: undefined, + globalStoragePath: "", + globalStorageUri: vscode.Uri.parse("file://ProjectPath/global"), + logPath: "", + logUri: vscode.Uri.parse("file://ProjectPath/log"), + asAbsolutePath: (rel: string) => rel, + extensionMode: vscode.ExtensionMode.Test, + secrets: { + get: async () => undefined, + store: async () => undefined, + delete: async () => false, + onDidChange: new vscode.EventEmitter().event, + } as unknown as vscode.SecretStorage, + environmentVariableCollection: { + // minimal stub; tests here don't rely on it + persistent: true, + replace: () => undefined, + append: () => undefined, + get: () => undefined, + forEach: () => undefined, + delete: () => undefined, + clear: () => undefined, + } as unknown as vscode.EnvironmentVariableCollection, + extension: undefined as unknown as vscode.Extension, + }; + contextStub = rawContext as unknown as vscode.ExtensionContext; + + const outputChannel: vscode.OutputChannel = { + name: "test", + append: () => undefined, + appendLine: () => undefined, + clear: () => undefined, + replace: (_value: string) => undefined, + show: () => undefined, + hide: () => undefined, + dispose: () => undefined, + }; + + // Subclass VscodeWrapper to override the outputChannel getter cleanly. + class TestVscodeWrapper extends VscodeWrapper { + public override get outputChannel(): vscode.OutputChannel { + return outputChannel; + } + } + vscodeWrapperStub = new TestVscodeWrapper(); }); teardown(() => { @@ -46,8 +89,8 @@ suite("PublishProjectWebViewController Tests", () => { test("constructor initializes state and derives database name", async () => { const projectPath = "c:/work/MySampleProject.sqlproj"; const controller = new PublishProjectWebViewController( - mockContext.object, - mockVscodeWrapper.object, + contextStub, + vscodeWrapperStub, projectPath, ); From 978735ee7340f30208f4a81d89c3841177b31006 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 24 Sep 2025 14:14:03 -0500 Subject: [PATCH 23/94] merging the base pr changes and little bit of refactoring --- localization/l10n/bundle.l10n.json | 1 + localization/xliff/vscode-mssql.xlf | 3 + src/constants/constants.ts | 11 +++ src/constants/locConstants.ts | 1 + src/publishProject/formComponentHelpers.ts | 15 ++- src/publishProject/projectUtils.ts | 46 +-------- .../publishProjectWebViewController.ts | 19 +--- .../components/PublishTargetSection.tsx | 45 ++++++--- .../pages/PublishProject/publishProject.tsx | 74 +++++++------- .../publishProjectStateProvider.tsx | 52 ++++------ src/sharedInterfaces/publishDialog.ts | 44 ++++++++- test/unit/publishProjectDialog.test.ts | 99 ++++++------------- 12 files changed, 195 insertions(+), 215 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 94dbb4a539..65ddf716eb 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1654,6 +1654,7 @@ "Publish Profile": "Publish Profile", "Select or enter a publish profile": "Select or enter a publish profile", "Database name is required": "Database name is required", + "SQLCMD Variables": "SQLCMD Variables", "Publish Target": "Publish Target", "Existing SQL server": "Existing SQL server", "Local development container": "Local development container", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 77ffd4fa1f..aa314c40a7 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -2551,6 +2551,9 @@ SQL database in Fabric (Preview) + + SQLCMD Variables + SVG diff --git a/src/constants/constants.ts b/src/constants/constants.ts index f5daafe3bc..e59114429c 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -317,3 +317,14 @@ export const PublishFormFields = { ContainerImageTag: "containerImageTag", AcceptContainerLicense: "acceptContainerLicense", } as const; + +// Group of all container-specific publish form field identifiers used together when +// the target is a local container. Centralizing this list avoids duplication in +// controllers (e.g., building active component arrays and computing hidden fields). +export const PublishFormContainerFields = [ + PublishFormFields.ContainerPort, + PublishFormFields.ContainerAdminPassword, + PublishFormFields.ContainerAdminPasswordConfirm, + PublishFormFields.ContainerImageTag, + PublishFormFields.AcceptContainerLicense, +] as const; diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index a0b7ae9431..30c19b51a0 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1197,6 +1197,7 @@ export class PublishProject { public static ServerLabel = l10n.t("Server"); public static DatabaseLabel = l10n.t("Database"); public static DatabaseRequiredMessage = l10n.t("Database name is required"); + public static SqlCmdVariablesLabel = l10n.t("SQLCMD Variables"); public static PublishTargetLabel = l10n.t("Publish Target"); public static PublishTargetExisting = l10n.t("Existing SQL server"); public static PublishTargetContainer = l10n.t("Local development container"); diff --git a/src/publishProject/formComponentHelpers.ts b/src/publishProject/formComponentHelpers.ts index c446dd65a5..6248c4f995 100644 --- a/src/publishProject/formComponentHelpers.ts +++ b/src/publishProject/formComponentHelpers.ts @@ -22,9 +22,9 @@ import { * (e.g. reading project metadata, fetching remote targets, etc.) */ export async function generatePublishFormComponents(): Promise< - Record + Record > { - const components: Record = { + const components: Record = { [constants.PublishFormFields.ProfileName]: { propertyName: constants.PublishFormFields.ProfileName, label: Loc.ProfileLabel, @@ -34,7 +34,7 @@ export async function generatePublishFormComponents(): Promise< [constants.PublishFormFields.ServerName]: { propertyName: constants.PublishFormFields.ServerName, label: Loc.ServerLabel, - required: false, + required: true, type: FormItemType.Input, }, [constants.PublishFormFields.DatabaseName]: { @@ -140,7 +140,14 @@ export async function generatePublishFormComponents(): Promise< }; }, }, - } as Record; + sqlCmdVariables: { + propertyName: "sqlCmdVariables", + label: Loc.SqlCmdVariablesLabel, + required: false, + type: FormItemType.Input, + hidden: true, + }, + }; return components; } diff --git a/src/publishProject/projectUtils.ts b/src/publishProject/projectUtils.ts index 03a88f186d..8fea333da9 100644 --- a/src/publishProject/projectUtils.ts +++ b/src/publishProject/projectUtils.ts @@ -8,7 +8,7 @@ import * as mssql from "vscode-mssql"; import * as constants from "../constants/constants"; import { SqlProjectsService } from "../services/sqlProjectsService"; -import { IPublishForm } from "../sharedInterfaces/publishDialog"; +// (Removed validatePublishForm; field-level validation now handled via individual FormItemSpec validators) // Shape returned by sqlProjectsService.getProjectProperties (partial, only fields we use) export interface ProjectProperties { @@ -130,50 +130,6 @@ export function getPublishServerName(target: string) { : constants.SqlServerName; } -/** - * Validates the publish form state to determine if all required fields are provided - * based on the selected publish target. - * - * @param formState The current form state to validate - * @returns true if the form is valid and ready for publish/script generation, false otherwise - */ -export function validatePublishForm(formState: IPublishForm): boolean { - // Always require publish target and database name - if (!formState.publishTarget || !formState.databaseName) { - return false; - } - - // For existing server, require server name - if (formState.publishTarget === constants.PublishTargets.EXISTING_SERVER) { - return !!formState.serverName; - } - - // For local container, validate container-specific required fields - if (formState.publishTarget === constants.PublishTargets.LOCAL_CONTAINER) { - // Check required container fields - const hasContainerPort = !!formState.containerPort; - const hasAdminPassword = !!formState.containerAdminPassword; - const hasPasswordConfirm = !!formState.containerAdminPasswordConfirm; - const hasImageTag = !!formState.containerImageTag; - const hasAcceptedLicense = !!formState.acceptContainerLicense; - - // Passwords must match - const passwordsMatch = - formState.containerAdminPassword === formState.containerAdminPasswordConfirm; - - return ( - hasContainerPort && - hasAdminPassword && - hasPasswordConfirm && - hasImageTag && - hasAcceptedLicense && - passwordsMatch - ); - } - - return false; -} - /* * Validates the SQL Server port number. */ diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 02a975e4e7..ca5ef4a7e2 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -134,7 +134,6 @@ export class PublishProjectWebViewController extends FormWebviewController< } await this.updateItemVisibility(); - this.updateState(); } /** Registers all reducers in pure (immutable) style */ @@ -193,13 +192,7 @@ export class PublishProjectWebViewController extends FormWebviewController< ] as (keyof IPublishForm)[]; if (state.formState.publishTarget === constants.PublishTargets.LOCAL_CONTAINER) { - activeComponents.push( - constants.PublishFormFields.ContainerPort, - constants.PublishFormFields.ContainerAdminPassword, - constants.PublishFormFields.ContainerAdminPasswordConfirm, - constants.PublishFormFields.ContainerImageTag, - constants.PublishFormFields.AcceptContainerLicense, - ); + activeComponents.push(...constants.PublishFormContainerFields); } return activeComponents; @@ -211,17 +204,9 @@ export class PublishProjectWebViewController extends FormWebviewController< const hidden: string[] = []; if (target === constants.PublishTargets.LOCAL_CONTAINER) { - // Hide server-specific fields when targeting local container hidden.push(constants.PublishFormFields.ServerName); } else if (target === constants.PublishTargets.EXISTING_SERVER) { - // Hide container-specific fields when targeting existing server - hidden.push( - constants.PublishFormFields.ContainerPort, - constants.PublishFormFields.ContainerAdminPassword, - constants.PublishFormFields.ContainerAdminPasswordConfirm, - constants.PublishFormFields.ContainerImageTag, - constants.PublishFormFields.AcceptContainerLicense, - ); + hidden.push(...constants.PublishFormContainerFields); } for (const component of Object.values(currentState.formComponents)) { diff --git a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx index 71426358ff..9698a1824c 100644 --- a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx @@ -55,6 +55,11 @@ const useStyles = makeStyles({ fontSize: "12px", marginLeft: "24px", }, + requiredAsterisk: { + color: tokens.colorStatusDangerForeground1, + fontWeight: 600, + marginLeft: "4px", + }, }); const containerFieldOrder: (keyof IPublishForm)[] = [ @@ -95,22 +100,32 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { return; } - // Set default port once when entering container mode - if (!context.state.formState.containerPort) { + const { formState, formComponents } = context.state; + + // Default container port if not already set (requested behavior: default to 1433) + if (!formState.containerPort) { context.formAction({ propertyName: constants.PublishFormFields.ContainerPort, isAction: false, value: constants.DefaultSqlPortNumber, + updateValidation: true, }); } - // Set default image tag if none selected - if (!context.state.formState.containerImageTag) { - context.formAction({ - propertyName: constants.PublishFormFields.ContainerImageTag, - isAction: false, - value: constants.dockerImageDefaultTag, - }); + // If image tag options were populated and user hasn't chosen yet, pick the first option. + if (!formState.containerImageTag) { + const tagComp = formComponents[constants.PublishFormFields.ContainerImageTag] as + | PublishDialogFormItemSpec + | undefined; + const firstOption = tagComp?.options?.[0]; + if (firstOption) { + context.formAction({ + propertyName: tagComp.propertyName, + isAction: false, + value: firstOption.value, + updateValidation: true, + }); + } } }, [isContainer, context]); @@ -157,8 +172,8 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => {
, data: CheckboxOnChangeData, @@ -167,6 +182,7 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { propertyName: comp.propertyName, isAction: false, value: data.checked === true, + updateValidation: true, }); }} /> @@ -180,10 +196,17 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { {licenseInfo.linkText} {licenseInfo.afterText} +
{isError && errorMessage && ( - {errorMessage} + + {errorMessage} + )}
); diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index abd1fa0b4d..fb2d557df2 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -7,13 +7,12 @@ import { useContext } from "react"; import { Button, makeStyles } from "@fluentui/react-components"; import { useFormStyles } from "../../common/forms/form.component"; import { PublishProjectStateProvider, PublishProjectContext } from "./publishProjectStateProvider"; +import { IPublishForm } from "../../../sharedInterfaces/publishDialog"; import { usePublishDialogSelector } from "./publishDialogSelector"; import { LocConstants } from "../../common/locConstants"; import { PublishProfileField } from "./components/PublishProfileSection"; import { PublishTargetSection } from "./components/PublishTargetSection"; import { ConnectionSection } from "./components/ConnectionSection"; -import { validatePublishForm } from "../../../publishProject/projectUtils"; -import { PublishFormContext } from "./types"; import * as constants from "../../../constants/constants"; const useStyles = makeStyles({ @@ -31,25 +30,6 @@ const useStyles = makeStyles({ }, }); -// Type guard to check if context has the required publish methods -function isPublishFormContext(context: unknown): context is PublishFormContext { - if (!context || typeof context !== "object") { - return false; - } - - const ctx = context as Record; - return ( - "publishNow" in ctx && - "generatePublishScript" in ctx && - "selectPublishProfile" in ctx && - "savePublishProfile" in ctx && - typeof ctx.publishNow === "function" && - typeof ctx.generatePublishScript === "function" && - typeof ctx.selectPublishProfile === "function" && - typeof ctx.savePublishProfile === "function" - ); -} - function PublishProjectDialog() { const classes = useStyles(); const formStyles = useFormStyles(); @@ -60,22 +40,46 @@ function PublishProjectDialog() { const formComponents = usePublishDialogSelector((s) => s.formComponents, Object.is); const formState = usePublishDialogSelector((s) => s.formState, Object.is); const inProgress = usePublishDialogSelector((s) => s.inProgress, Object.is); - + console.debug(); // Check if component is properly initialized and ready for user interaction - const isComponentReady = isPublishFormContext(context) && !!formComponents && !!formState; + const isComponentReady = !!context && !!formComponents && !!formState; + + // Check if any visible component has an explicit validation error. + // NOTE: Relying solely on component.validation misses the case where a required field is still untouched + // and thus has no validation state yet. We therefore also perform a required-value presence check below. + const hasValidationErrors = + isComponentReady && formComponents + ? Object.values(formComponents).some( + (component) => + !component.hidden && + component.validation !== undefined && + component.validation.isValid === false, + ) + : false; - // Check if all required fields are provided based on publish target - const isFormValid = isComponentReady && validatePublishForm(formState); + // Identify missing required values for visible components (treat empty string / whitespace as missing) + const hasMissingRequiredValues = + isComponentReady && formComponents && formState + ? Object.values(formComponents).some((component) => { + if (component.hidden || !component.required) { + return false; + } + const key = component.propertyName as keyof IPublishForm; + const raw = formState[key]; + if (raw === undefined) { + return true; + } + return typeof raw === "string" && raw.trim().length === 0; + }) + : true; // if not ready, treat as missing - // Buttons should be disabled when: - // - Component is not ready (missing context, form components, or form state) - // - Operation is in progress - // - Form validation fails - const buttonsDisabled = !isComponentReady || inProgress || !isFormValid; + // Disabled criteria (previously inverted): disable when not ready, in progress, validation errors, or missing required fields + const readyToPublish = + !isComponentReady || inProgress || hasValidationErrors || hasMissingRequiredValues; - // Generate script should only be available for existing server target - const generateScriptDisabled = - buttonsDisabled || formState?.publishTarget !== constants.PublishTargets.EXISTING_SERVER; + // Generate script only for existing server target + const readyToGenerateScript = + readyToPublish || formState?.publishTarget !== constants.PublishTargets.EXISTING_SERVER; if (!isComponentReady) { return
Loading...
; @@ -92,13 +96,13 @@ function PublishProjectDialog() {
diff --git a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx index 09cf8fc499..f83cade6cb 100644 --- a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx +++ b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx @@ -5,45 +5,34 @@ import { createContext, useMemo } from "react"; import { useVscodeWebview2 } from "../../common/vscodeWebviewProvider2"; +import { getCoreRPCs2 } from "../../common/utils"; import { WebviewRpc } from "../../common/rpc"; import { PublishDialogReducers, PublishDialogState, IPublishForm, + PublishProjectProvider, } from "../../../sharedInterfaces/publishDialog"; +import { FormEvent } from "../../../sharedInterfaces/form"; +import { + LoggerLevel, + WebviewTelemetryActionEvent, + WebviewTelemetryErrorEvent, +} from "../../../sharedInterfaces/webview"; -export interface PublishProjectContextValue { - // Use inner state directly for form system generics - state?: PublishDialogState; // snapshot accessor - formAction: (event: PublishFormActionEvent) => void; - publishNow: (payload?: PublishNowPayload) => void; - generatePublishScript: () => void; - selectPublishProfile: () => void; - savePublishProfile: (profileName: string) => void; - setPublishValues: ( - values: Partial & { projectFilePath?: string }, - ) => void; +export interface PublishProjectContextProps extends PublishProjectProvider { + readonly state?: PublishDialogState; + log(message: string, level?: LoggerLevel): void; + sendActionEvent(event: WebviewTelemetryActionEvent): void; + sendErrorEvent(event: WebviewTelemetryErrorEvent): void; + /** Advanced escape hatch; prefer using typed provider methods */ extensionRpc: WebviewRpc; } -// Event payload coming from shared FormField components -export interface PublishFormActionEvent { - propertyName: keyof IPublishForm; - value: string | boolean; - isAction: boolean; // true when triggered by an action button on the field - updateValidation?: boolean; // optional flag to force validation -} - // Optional payload for publishNow future expansion -export interface PublishNowPayload { - projectFilePath?: string; - databaseName?: string; - connectionUri?: string; - sqlCmdVariables?: { [key: string]: string }; - publishProfilePath?: string; -} +export type PublishNowPayload = Parameters[0]; -export const PublishProjectContext = createContext( +export const PublishProjectContext = createContext( undefined, ); @@ -55,7 +44,7 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } PublishDialogReducers >(); - const value = useMemo( + const value = useMemo( () => ({ get state() { const inner = getSnapshot(); // inner PublishDialogState @@ -64,7 +53,8 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } } return inner; }, - formAction: (event: PublishFormActionEvent) => + ...getCoreRPCs2(extensionRpc), + formAction: (event: FormEvent) => extensionRpc.action("formAction", { event }), publishNow: (payload?: PublishNowPayload) => extensionRpc.action("publishNow", payload ?? {}), @@ -72,9 +62,7 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } selectPublishProfile: () => extensionRpc.action("selectPublishProfile"), savePublishProfile: (profileName: string) => extensionRpc.action("savePublishProfile", { profileName }), - setPublishValues: ( - values: Partial & { projectFilePath?: string }, - ) => extensionRpc.action("setPublishValues", values), + setPublishValues: (values) => extensionRpc.action("setPublishValues", values), extensionRpc, }), [extensionRpc, getSnapshot], diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 581d7f8cfd..336f97430a 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as constants from "../constants/constants"; -import { FormItemSpec, FormState, FormReducers } from "./form"; +import { FormItemSpec, FormState, FormReducers, FormEvent } from "./form"; /** * Data fields shown in the Publish form. @@ -44,9 +44,8 @@ export interface PublishDialogState */ export interface PublishDialogFormItemSpec extends FormItemSpec { - isAdvancedOption?: boolean; - optionCategory?: string; - optionCategoryLabel?: string; + // (Removed advanced option metadata: was isAdvancedOption/optionCategory/optionCategoryLabel) + // Reintroduce when the Publish dialog gains an "Advanced" section with grouped fields. } /** @@ -79,3 +78,40 @@ export interface PublishDialogReducers extends FormReducers { selectPublishProfile: {}; savePublishProfile: { profileName: string }; } + +/** + * Public operations + form dispatch surface for the Publish Project webview. + * React context a stable, typed contract while keeping implementation details (raw RPC naming, snapshot plumbing) encapsulated. + */ +export interface PublishProjectProvider { + /** Dispatch a single field value change or field-level action */ + formAction(event: FormEvent): void; + /** Execute an immediate publish using current (or overridden) form values */ + publishNow(payload?: { + projectFilePath?: string; + databaseName?: string; + connectionUri?: string; + sqlCmdVariables?: { [key: string]: string }; + publishProfilePath?: string; + }): void; + /** Generate (but do not execute) a publish script */ + generatePublishScript(): void; + /** Choose a publish profile file and apply (may partially override form state) */ + selectPublishProfile(): void; + /** Persist current form state as a named profile */ + savePublishProfile(profileName: string): void; + /** Bulk set form values (e.g., after loading a profile) */ + setPublishValues(values: { + profileName?: string; + serverName?: string; + databaseName?: string; + publishTarget?: "existingServer" | "localContainer"; + sqlCmdVariables?: { [key: string]: string }; + containerPort?: string; + containerAdminPassword?: string; + containerAdminPasswordConfirm?: string; + containerImageTag?: string; + acceptContainerLicense?: boolean; + projectFilePath?: string; + }): void; +} diff --git a/test/unit/publishProjectDialog.test.ts b/test/unit/publishProjectDialog.test.ts index 343b1ae943..15a1ffff02 100644 --- a/test/unit/publishProjectDialog.test.ts +++ b/test/unit/publishProjectDialog.test.ts @@ -10,6 +10,10 @@ import * as constants from "../../src/constants/constants"; import { expect } from "chai"; import VscodeWrapper from "../../src/controllers/vscodeWrapper"; import { PublishProjectWebViewController } from "../../src/publishProject/publishProjectWebViewController"; +import { + validateSqlServerPortNumber, + isValidSqlAdminPassword, +} from "../../src/publishProject/projectUtils"; suite("PublishProjectWebViewController", () => { let sandbox: sinon.SinonSandbox; @@ -158,72 +162,33 @@ suite("PublishProjectWebViewController", () => { expect(controller.state.formComponents.serverName?.hidden).to.not.be.true; }); - test("validatePublishForm correctly validates container target fields", async () => { - // Import the validation function - const { validatePublishForm } = await import("../../src/publishProject/projectUtils"); - - // Test invalid cases - expect(validatePublishForm({})).to.be.false; // No target or database - expect(validatePublishForm({ publishTarget: constants.PublishTargets.LOCAL_CONTAINER })).to - .be.false; // No database - expect( - validatePublishForm({ - publishTarget: constants.PublishTargets.LOCAL_CONTAINER, - databaseName: "TestDB", - }), - ).to.be.false; // Missing container fields - - expect( - validatePublishForm({ - publishTarget: constants.PublishTargets.LOCAL_CONTAINER, - databaseName: "TestDB", - containerPort: "1433", - containerAdminPassword: "Password123!", - containerAdminPasswordConfirm: "DifferentPassword", // Passwords don't match - containerImageTag: "2022-latest", - acceptContainerLicense: true, - }), - ).to.be.false; // Passwords don't match - - expect( - validatePublishForm({ - publishTarget: constants.PublishTargets.LOCAL_CONTAINER, - databaseName: "TestDB", - containerPort: "1433", - containerAdminPassword: "Password123!", - containerAdminPasswordConfirm: "Password123!", - containerImageTag: "2022-latest", - acceptContainerLicense: false, // License not accepted - }), - ).to.be.false; // License not accepted - - // Test valid case - expect( - validatePublishForm({ - publishTarget: constants.PublishTargets.LOCAL_CONTAINER, - databaseName: "TestDB", - containerPort: "1433", - containerAdminPassword: "Password123!", - containerAdminPasswordConfirm: "Password123!", - containerImageTag: "2022-latest", - acceptContainerLicense: true, - }), - ).to.be.true; // All fields valid - - // Test existing server validation - expect( - validatePublishForm({ - publishTarget: constants.PublishTargets.EXISTING_SERVER, - databaseName: "TestDB", - serverName: "localhost", - }), - ).to.be.true; // Valid existing server - - expect( - validatePublishForm({ - publishTarget: constants.PublishTargets.EXISTING_SERVER, - databaseName: "TestDB", - }), - ).to.be.false; // Missing server name + test("field-level validators enforce container and server requirements", async () => { + // Port validation + expect(validateSqlServerPortNumber("1433")).to.be.true; + expect(validateSqlServerPortNumber(1433)).to.be.true; + expect(validateSqlServerPortNumber(""), "empty string invalid").to.be.false; + expect(validateSqlServerPortNumber("0"), "port 0 invalid").to.be.false; + expect(validateSqlServerPortNumber("70000"), "out-of-range port invalid").to.be.false; + + // Password complexity validation + expect(isValidSqlAdminPassword("Password123!"), "complex password valid").to.be.true; + expect(isValidSqlAdminPassword("password"), "simple lowercase invalid").to.be.false; + expect(isValidSqlAdminPassword("PASSWORD"), "simple uppercase invalid").to.be.false; + expect(isValidSqlAdminPassword("Passw0rd"), "missing symbol still ok? need 3 classes").to.be + .true; + + // Password confirm logic (mirrors confirm field validator semantics) + const pwd = "Password123!"; + const confirmOk = pwd === "Password123!"; + const mismatch = "Different" + ""; // widen type to plain string to avoid literal compare lint + const confirmBad = pwd === mismatch; + expect(confirmOk).to.be.true; + expect(confirmBad).to.be.false; + + // License acceptance toggle semantics + const licenseAccepted = true; + const licenseNotAccepted = false; + expect(licenseAccepted).to.be.true; + expect(licenseNotAccepted).to.be.false; }); }); From 78b61179b06c49fd6b3278e2c2e5b6787e3c6606 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Mon, 29 Sep 2025 11:07:14 -0500 Subject: [PATCH 24/94] confirm password validation with both pwd fields --- .../components/PublishTargetSection.tsx | 118 +++++------------- .../pages/PublishProject/publishProject.tsx | 12 +- 2 files changed, 41 insertions(+), 89 deletions(-) diff --git a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx index 9698a1824c..4f54c9e669 100644 --- a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { useContext, useEffect } from "react"; -import { makeStyles, Checkbox, tokens, CheckboxOnChangeData } from "@fluentui/react-components"; +import { makeStyles } from "@fluentui/react-components"; import { PublishProjectContext } from "../publishProjectStateProvider"; import { usePublishDialogSelector } from "../publishDialogSelector"; import { @@ -14,7 +14,6 @@ import { } from "../../../../sharedInterfaces/publishDialog"; import { FormField } from "../../../common/forms/form.component"; import { PublishFormContext } from "../types"; -import { parseLicenseText } from "../../../../publishProject/dockerUtils"; import * as constants from "../../../../constants/constants"; const useStyles = makeStyles({ @@ -32,34 +31,7 @@ const useStyles = makeStyles({ paddingLeft: "16px", borderLeft: "2px solid var(--vscode-editorWidget-border, #8883)", }, - licenseBlock: { - display: "flex", - flexDirection: "column", - gap: "4px", - maxWidth: "100%", - }, - licenseLabel: { - lineHeight: "1.3", - }, - licenseContainer: { - display: "flex", - alignItems: "center", - gap: "8px", - }, - licenseLink: { - textDecoration: "underline", - color: "var(--vscode-textLink-foreground)", - }, - licenseError: { - color: tokens.colorStatusDangerForeground1, - fontSize: "12px", - marginLeft: "24px", - }, - requiredAsterisk: { - color: tokens.colorStatusDangerForeground1, - fontWeight: 600, - marginLeft: "4px", - }, + // (License-specific styles removed; using generic FormField rendering now) }); const containerFieldOrder: (keyof IPublishForm)[] = [ @@ -89,6 +61,16 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { (a, b) => a === b, ); + // Track password & confirm password values for cross-field validation + const containerPassword = usePublishDialogSelector( + (s) => s.formState[constants.PublishFormFields.ContainerAdminPassword], + (a, b) => a === b, + ); + const containerPasswordConfirm = usePublishDialogSelector( + (s) => s.formState[constants.PublishFormFields.ContainerAdminPasswordConfirm], + (a, b) => a === b, + ); + if (!context || !targetSpec || targetSpec.hidden) { return undefined; } @@ -129,6 +111,24 @@ export const PublishTargetSection: React.FC<{ idx: number }> = ({ idx }) => { } }, [isContainer, context]); + // Revalidate confirm password whenever the primary password changes so stale + // (previously matching) confirm value doesn't remain marked valid. + useEffect(() => { + if (!isContainer) { + return; + } + // Only attempt revalidation if the user has entered something in confirm field. + // We still want the presence logic to handle the empty confirm case as "missing". + if (containerPasswordConfirm !== undefined && containerPasswordConfirm !== "") { + context.formAction({ + propertyName: constants.PublishFormFields.ContainerAdminPasswordConfirm, + isAction: false, + value: containerPasswordConfirm as string, + updateValidation: true, + }); + } + }, [containerPassword, isContainer, containerPasswordConfirm, context]); + return (
= ({ idx }) => { return undefined; } - // License checkbox special rendering - if (name === constants.PublishFormFields.AcceptContainerLicense) { - const isChecked = - (context.state.formState[ - comp.propertyName as keyof IPublishForm - ] as boolean) ?? false; - - const validation = comp.validation; - const isError = validation !== undefined && !validation.isValid; - const errorMessage = isError ? validation.validationMessage : undefined; - - const licenseInfo = parseLicenseText(comp.label || ""); - - return ( -
-
- , - data: CheckboxOnChangeData, - ) => { - context.formAction({ - propertyName: comp.propertyName, - isAction: false, - value: data.checked === true, - updateValidation: true, - }); - }} - /> - - {licenseInfo.beforeText} - - {licenseInfo.linkText} - - {licenseInfo.afterText} - - -
- {isError && errorMessage && ( - - {errorMessage} - - )} -
- ); - } - return ( 0 + return false; }) : true; // if not ready, treat as missing From 00c29655837fdf443cdea9079a1e43d3f19650cd Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 30 Sep 2025 01:43:30 -0500 Subject: [PATCH 25/94] addressing code review comments --- .../publishProjectWebViewController.ts | 19 --- .../components/ConnectionSection.tsx | 119 ++++++++++-------- .../components/PublishProfileSection.tsx | 88 +++++++------ .../components/PublishTargetSection.tsx | 74 ++++++----- .../pages/PublishProject/publishProject.tsx | 6 +- .../publishProjectStateProvider.tsx | 27 +--- src/sharedInterfaces/publishDialog.ts | 19 +-- .../publishProjectWebViewController.test.ts | 41 +----- 8 files changed, 175 insertions(+), 218 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 1f930d9682..9b34cf964a 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -95,25 +95,6 @@ export class PublishProjectWebViewController extends FormWebviewController< } private registerRpcHandlers(): void { - // setPublishValues - this.registerReducer( - "setPublishValues", - async ( - state: PublishDialogState, - payload: Partial & { projectFilePath?: string }, - ) => { - if (payload) { - state.formState = { ...state.formState, ...payload }; - if (payload.projectFilePath) { - state.projectFilePath = payload.projectFilePath; - } - } - // Re-evaluate visibility if any controlling fields changed - await this.updateItemVisibility(); - return state; - }, - ); - this.registerReducer("publishNow", async (state: PublishDialogState) => { // TODO: implement actual publish logic (currently just clears inProgress) state.inProgress = false; diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx index 9f88aff59c..f3363c5173 100644 --- a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -3,67 +3,86 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { useContext } from "react"; +import { useContext, useState, useEffect } from "react"; import { PublishProjectContext } from "../publishProjectStateProvider"; import { usePublishDialogSelector } from "../publishDialogSelector"; -import { FormField } from "../../../common/forms/form.component"; -import { - IPublishForm, - PublishDialogState, - PublishDialogFormItemSpec, -} from "../../../../sharedInterfaces/publishDialog"; -import { FormContextProps } from "../../../../sharedInterfaces/form"; +import { Field, Input } from "@fluentui/react-components"; +import { FormItemType } from "../../../../sharedInterfaces/form"; +import type { IPublishForm } from "../../../../sharedInterfaces/publishDialog"; -// Context type reuse -interface PublishFormContext - extends FormContextProps { - publishNow: () => void; - generatePublishScript: () => void; - selectPublishProfile: () => void; - savePublishProfile: (profileName: string) => void; -} +export const ConnectionSection: React.FC = () => { + const publishCtx = useContext(PublishProjectContext); + const serverComponent = usePublishDialogSelector((s) => s.formComponents.serverName); + const databaseComponent = usePublishDialogSelector((s) => s.formComponents.databaseName); + const serverValue = usePublishDialogSelector((s) => s.formState.serverName); + const databaseValue = usePublishDialogSelector((s) => s.formState.databaseName); -export const ConnectionSection: React.FC<{ startIdx: number }> = ({ startIdx }) => { - const context = useContext(PublishProjectContext) as PublishFormContext | undefined; + const [localServer, setLocalServer] = useState(serverValue || ""); + const [localDatabase, setLocalDatabase] = useState(databaseValue || ""); - const serverComponent = usePublishDialogSelector((s) => s.formComponents.serverName, Object.is); - const databaseComponent = usePublishDialogSelector( - (s) => s.formComponents.databaseName, - Object.is, - ); + useEffect(() => setLocalServer(serverValue || ""), [serverValue]); + useEffect(() => setLocalDatabase(databaseValue || ""), [databaseValue]); - if (!context) { + if (!publishCtx) { return undefined; } + const renderInput = ( + component: + | { + propertyName: string; + hidden?: boolean; + required?: boolean; + label: string; + placeholder?: string; + validation?: { isValid: boolean; validationMessage?: string }; + type: FormItemType; + } + | undefined, + value: string, + setValue: (v: string) => void, + ) => { + if (!component || component.hidden) { + return undefined; + } + if (component.type !== FormItemType.Input) { + return undefined; + } + return ( + } + validationMessage={component.validation?.validationMessage} + validationState={ + component.validation + ? component.validation.isValid + ? "none" + : "error" + : "none" + } + orientation="horizontal"> + { + setValue(data.value); + publishCtx.formAction({ + propertyName: component.propertyName as keyof IPublishForm, + isAction: false, + value: data.value, + }); + }} + /> + + ); + }; + return ( <> - {serverComponent && !serverComponent.hidden && ( - - context={context} - component={serverComponent} - idx={startIdx} - props={{ orientation: "horizontal" }} - /> - )} - {databaseComponent && !databaseComponent.hidden && ( - - context={context} - component={databaseComponent} - idx={startIdx + 1} - props={{ orientation: "horizontal" }} - /> - )} + {renderInput(serverComponent, localServer, setLocalServer)} + {renderInput(databaseComponent, localDatabase, setLocalDatabase)} ); }; diff --git a/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx index 2a4ea1a58e..20b53968e6 100644 --- a/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx @@ -3,28 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Button, makeStyles } from "@fluentui/react-components"; -import { useContext } from "react"; -import { FormField, useFormStyles } from "../../../common/forms/form.component"; +import { Button, makeStyles, Field, Input } from "@fluentui/react-components"; +import { useContext, useState, useEffect } from "react"; +import { useFormStyles } from "../../../common/forms/form.component"; import { LocConstants } from "../../../common/locConstants"; import { PublishProjectContext } from "../publishProjectStateProvider"; -import { FormContextProps } from "../../../../sharedInterfaces/form"; -import { - IPublishForm, - PublishDialogFormItemSpec, - PublishDialogState, -} from "../../../../sharedInterfaces/publishDialog"; +import { usePublishDialogSelector } from "../publishDialogSelector"; +import type { IPublishForm } from "../../../../sharedInterfaces/publishDialog"; +import { FormItemType } from "../../../../sharedInterfaces/form"; /** * Extended context type including the extra publish profile actions we expose. */ -type PublishFormContext = FormContextProps< - IPublishForm, - PublishDialogState, - PublishDialogFormItemSpec -> & { +type PublishFormActions = { selectPublishProfile?: () => void; savePublishProfile?: (profileName: string) => void; + formAction: (args: { + propertyName: keyof IPublishForm; + isAction: boolean; + value?: unknown; + }) => void; }; const useStyles = makeStyles({ @@ -49,35 +47,55 @@ const useStyles = makeStyles({ }, }); -/** - * Custom field wrapper for Publish Profile. - * Renders the generic FormField for the text input and adds the action buttons alongside it. - */ -export const PublishProfileField = (props: { idx: number }) => { - const { idx } = props; +// Publish profile name input with action buttons (select & save) rendered inline via selectors. +export const PublishProfileField: React.FC = () => { const classes = useStyles(); const formStyles = useFormStyles(); const loc = LocConstants.getInstance().publishProject; - const context = useContext(PublishProjectContext) as PublishFormContext | undefined; - if (!context || !context.state) { + const context = useContext(PublishProjectContext) as PublishFormActions | undefined; + const component = usePublishDialogSelector((s) => s.formComponents.profileName); + const value = usePublishDialogSelector((s) => s.formState.profileName); + const [localValue, setLocalValue] = useState(value || ""); + + useEffect(() => setLocalValue(value || ""), [value]); + + if (!context || !component || component.hidden) { + return undefined; + } + if (component.type !== FormItemType.Input) { return undefined; } - const component = context.state.formComponents.profileName as PublishDialogFormItemSpec; return (
- - context={context} - component={component} - idx={idx} - props={{ orientation: "horizontal" }} - /> + } + validationMessage={component.validation?.validationMessage} + validationState={ + component.validation + ? component.validation.isValid + ? "none" + : "error" + : "none" + } + orientation="horizontal"> + { + setLocalValue(data.value); + context.formAction({ + propertyName: component.propertyName as keyof IPublishForm, + isAction: false, + value: data.value, + }); + }} + /> +
From f0b216a6fbe852668b772d8829b6d47066db0f6d Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 7 Oct 2025 11:00:00 -0500 Subject: [PATCH 36/94] fixing build errors --- .../pages/PublishProject/components/ConnectionSection.tsx | 4 ++-- .../pages/PublishProject/components/PublishProfileSection.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx index 4c3a9ebf75..cbe7706047 100644 --- a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -27,8 +27,8 @@ export const ConnectionSection: React.FC = () => { return ( <> - {renderInput(serverComponent, localServer, setLocalServer, publishCtx)} - {renderInput(databaseComponent, localDatabase, setLocalDatabase, publishCtx)} + {renderInput(serverComponent, localServer, setLocalServer)} + {renderInput(databaseComponent, localDatabase, setLocalDatabase)} ); }; diff --git a/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx index 28d817ab6b..74fc09dcda 100644 --- a/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx @@ -57,7 +57,7 @@ export const PublishProfileField: React.FC = () => { return (
- {renderInput(component, localValue, setLocalValue, context)} + {renderInput(component, localValue, setLocalValue)}
diff --git a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx index 3b7b721c4c..ff45f1329b 100644 --- a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx +++ b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx @@ -39,7 +39,7 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } generatePublishScript: () => extensionRpc.action("generatePublishScript"), selectPublishProfile: () => extensionRpc.action("selectPublishProfile"), savePublishProfile: (publishProfileName: string) => - extensionRpc.action("savePublishProfile", { profileName: publishProfileName }), + extensionRpc.action("savePublishProfile", { publishProfileName }), extensionRpc, }), [extensionRpc], diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 8cf8168d88..07d573c34d 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -63,7 +63,7 @@ export interface PublishDialogReducers extends FormReducers { generatePublishScript: {}; openPublishAdvanced: {}; selectPublishProfile: {}; - savePublishProfile: { profileName: string }; + savePublishProfile: { publishProfileName: string }; } /** diff --git a/test/unit/publishProjectDialog.test.ts b/test/unit/publishProjectDialog.test.ts deleted file mode 100644 index a9b37194cb..0000000000 --- a/test/unit/publishProjectDialog.test.ts +++ /dev/null @@ -1,593 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from "vscode"; -import * as sinon from "sinon"; -import * as constants from "../../src/constants/constants"; -import { expect } from "chai"; -import VscodeWrapper from "../../src/controllers/vscodeWrapper"; -import { PublishProjectWebViewController } from "../../src/publishProject/publishProjectWebViewController"; -import { - validateSqlServerPortNumber, - isValidSqlAdminPassword, -} from "../../src/publishProject/projectUtils"; - -/** - * UI and Form interaction tests for Publish Project Dialog - * Tests form field behavior, visibility, user interactions, and validators - * Controller/state logic tests are in publishProjectWebViewController.test.ts - */ -suite("PublishProjectWebViewController", () => { - let sandbox: sinon.SinonSandbox; - let mockContext: vscode.ExtensionContext; - let mockVscodeWrapper: VscodeWrapper; - let mockOutputChannel: vscode.OutputChannel; - let workspaceConfigStub: sinon.SinonStub; - - const projectPath = "c:/work/ContainerProject.sqlproj"; - - setup(() => { - sandbox = sinon.createSandbox(); - - // Create mock output channel - mockOutputChannel = { - append: sandbox.stub(), - appendLine: sandbox.stub(), - clear: sandbox.stub(), - show: sandbox.stub(), - hide: sandbox.stub(), - dispose: sandbox.stub(), - replace: sandbox.stub(), - name: "Test Output", - } as unknown as vscode.OutputChannel; - - // Create minimal context stub - only what the controller actually uses - mockContext = { - extensionUri: vscode.Uri.parse("file://fakePath"), - extensionPath: "fakePath", - subscriptions: [], - } as vscode.ExtensionContext; - - // Create stub VscodeWrapper - mockVscodeWrapper = { - outputChannel: mockOutputChannel, - } as unknown as VscodeWrapper; - - // Stub workspace configuration for preview features - workspaceConfigStub = sandbox.stub(vscode.workspace, "getConfiguration"); - workspaceConfigStub.withArgs("sqlDatabaseProjects").returns({ - get: sandbox.stub().withArgs("enablePreviewFeatures").returns(false), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - }); - - teardown(() => { - sandbox.restore(); - }); - - test("container target values are properly saved to state", async () => { - // Arrange - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - ); - - // Wait for async initialization to complete - await controller.initialized.promise; - - // Access internal reducer handlers map to invoke reducers directly - const reducerHandlers = controller["_reducerHandlers"] as Map; - const formAction = reducerHandlers.get("formAction"); - expect(formAction, "formAction reducer should be registered").to.exist; - - // Set target to localContainer first - await formAction(controller.state, { - event: { - propertyName: "publishTarget", - value: constants.PublishTargets.LOCAL_CONTAINER, - isAction: false, - }, - }); - - // Act - Test updating container port - await formAction(controller.state, { - event: { propertyName: "containerPort", value: "1434", isAction: false }, - }); - - // Act - Test updating admin password - await formAction(controller.state, { - event: { - propertyName: "containerAdminPassword", - value: "TestPassword123!", - isAction: false, - }, - }); - - // Act - Test updating password confirmation - await formAction(controller.state, { - event: { - propertyName: "containerAdminPasswordConfirm", - value: "TestPassword123!", - isAction: false, - }, - }); - - // Act - Test updating image tag - await formAction(controller.state, { - event: { propertyName: "containerImageTag", value: "2022-latest", isAction: false }, - }); - - // Act - Test accepting license agreement - await formAction(controller.state, { - event: { propertyName: "acceptContainerLicense", value: true, isAction: false }, - }); - - // Assert - Verify all values are saved to state - expect(controller.state.formState.publishTarget).to.equal( - constants.PublishTargets.LOCAL_CONTAINER, - ); - expect(controller.state.formState.containerPort).to.equal("1434"); - expect(controller.state.formState.containerAdminPassword).to.equal("TestPassword123!"); - expect(controller.state.formState.containerAdminPasswordConfirm).to.equal( - "TestPassword123!", - ); - expect(controller.state.formState.containerImageTag).to.equal("2022-latest"); - expect(controller.state.formState.acceptContainerLicense).to.equal(true); - - // Assert - Verify form components exist for container fields - expect(controller.state.formComponents.containerPort).to.exist; - expect(controller.state.formComponents.containerAdminPassword).to.exist; - expect(controller.state.formComponents.containerAdminPasswordConfirm).to.exist; - expect(controller.state.formComponents.containerImageTag).to.exist; - expect(controller.state.formComponents.acceptContainerLicense).to.exist; - - // Assert - Verify container components are not hidden when target is localContainer - expect(controller.state.formComponents.containerPort?.hidden).to.not.be.true; - expect(controller.state.formComponents.containerAdminPassword?.hidden).to.not.be.true; - expect(controller.state.formComponents.containerAdminPasswordConfirm?.hidden).to.not.be - .true; - expect(controller.state.formComponents.containerImageTag?.hidden).to.not.be.true; - expect(controller.state.formComponents.acceptContainerLicense?.hidden).to.not.be.true; - }); - - test("container fields are hidden when target is existingServer", async () => { - // Arrange - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - ); - - // Wait for async initialization to complete - await controller.initialized.promise; - - // Access internal reducer handlers map to invoke reducers directly - const reducerHandlers = controller["_reducerHandlers"] as Map; - const formAction = reducerHandlers.get("formAction"); - expect(formAction, "formAction reducer should be registered").to.exist; - - // Set target to existingServer - await formAction(controller.state, { - event: { - propertyName: "publishTarget", - value: constants.PublishTargets.EXISTING_SERVER, - isAction: false, - }, - }); - - // Assert - Verify container components are hidden when target is existingServer - expect(controller.state.formComponents.containerPort?.hidden).to.be.true; - expect(controller.state.formComponents.containerAdminPassword?.hidden).to.be.true; - expect(controller.state.formComponents.containerAdminPasswordConfirm?.hidden).to.be.true; - expect(controller.state.formComponents.containerImageTag?.hidden).to.be.true; - expect(controller.state.formComponents.acceptContainerLicense?.hidden).to.be.true; - - // Assert - Verify server component is not hidden - expect(controller.state.formComponents.serverName?.hidden).to.not.be.true; - }); - - test("container fields are hidden when target is NEW_AZURE_SERVER", async () => { - // Arrange - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - ); - - // Wait for async initialization to complete - await controller.initialized.promise; - - // Access internal reducer handlers map to invoke reducers directly - const reducerHandlers = controller["_reducerHandlers"] as Map; - const formAction = reducerHandlers.get("formAction"); - expect(formAction, "formAction reducer should be registered").to.exist; - - // Set target to NEW_AZURE_SERVER - await formAction(controller.state, { - event: { - propertyName: "publishTarget", - value: constants.PublishTargets.NEW_AZURE_SERVER, - isAction: false, - }, - }); - - // Assert - Verify container components are hidden when target is NEW_AZURE_SERVER - expect(controller.state.formComponents.containerPort?.hidden).to.be.true; - expect(controller.state.formComponents.containerAdminPassword?.hidden).to.be.true; - expect(controller.state.formComponents.containerAdminPasswordConfirm?.hidden).to.be.true; - expect(controller.state.formComponents.containerImageTag?.hidden).to.be.true; - expect(controller.state.formComponents.acceptContainerLicense?.hidden).to.be.true; - - // Assert - Verify server component is not hidden - expect(controller.state.formComponents.serverName?.hidden).to.not.be.true; - }); - - test("publish target dropdown contains correct options for SQL Server project", async () => { - // Arrange - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - ); - - // Wait for async initialization to complete - await controller.initialized.promise; - - // Assert - Verify publish target component exists and has correct options - const publishTargetComponent = controller.state.formComponents.publishTarget; - expect(publishTargetComponent).to.exist; - expect(publishTargetComponent.options).to.exist; - expect(publishTargetComponent.options?.length).to.equal(2); - - // Verify option values and display names for SQL Server project - const existingServerOption = publishTargetComponent.options?.find( - (opt) => opt.value === constants.PublishTargets.EXISTING_SERVER, - ); - const containerOption = publishTargetComponent.options?.find( - (opt) => opt.value === constants.PublishTargets.LOCAL_CONTAINER, - ); - - expect(existingServerOption).to.exist; - expect(containerOption).to.exist; - - // Should NOT have NEW_AZURE_SERVER for non-Azure projects - const azureOption = publishTargetComponent.options?.find( - (opt) => opt.value === constants.PublishTargets.NEW_AZURE_SERVER, - ); - expect(azureOption).to.be.undefined; - }); - - test("publish target dropdown shows Azure-specific labels for Azure SQL project", async () => { - // Arrange - Create mock SQL Projects Service that returns AzureV12 target version - const mockSqlProjectsService = { - getProjectProperties: sandbox.stub().resolves({ - success: true, - projectGuid: "test-guid", - databaseSchemaProvider: - "Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider", - }), - }; - - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - mockSqlProjectsService as any, // eslint-disable-line @typescript-eslint/no-explicit-any - ); - - // Wait for async initialization to complete - await controller.initialized.promise; - - // Assert - Verify publish target component has Azure-specific labels - const publishTargetComponent = controller.state.formComponents.publishTarget; - expect(publishTargetComponent).to.exist; - expect(publishTargetComponent.options).to.exist; - - const existingServerOption = publishTargetComponent.options?.find( - (opt) => opt.value === constants.PublishTargets.EXISTING_SERVER, - ); - const containerOption = publishTargetComponent.options?.find( - (opt) => opt.value === constants.PublishTargets.LOCAL_CONTAINER, - ); - - expect(existingServerOption).to.exist; - expect(existingServerOption?.displayName).to.equal("Existing Azure SQL logical server"); - - expect(containerOption).to.exist; - expect(containerOption?.displayName).to.equal("New SQL Server local development container"); - }); - - test("NEW_AZURE_SERVER option appears when preview features enabled for Azure SQL project", async () => { - // Arrange - Enable preview features - workspaceConfigStub.withArgs("sqlDatabaseProjects").returns({ - get: sandbox.stub().withArgs("enablePreviewFeatures").returns(true), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - - // Create mock SQL Projects Service that returns AzureV12 target version - const mockSqlProjectsService = { - getProjectProperties: sandbox.stub().resolves({ - success: true, - projectGuid: "test-guid", - databaseSchemaProvider: - "Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider", - }), - }; - - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - mockSqlProjectsService as any, // eslint-disable-line @typescript-eslint/no-explicit-any - ); - - // Wait for async initialization to complete - await controller.initialized.promise; - - // Assert - Verify NEW_AZURE_SERVER option exists - const publishTargetComponent = controller.state.formComponents.publishTarget; - expect(publishTargetComponent).to.exist; - expect(publishTargetComponent.options).to.exist; - expect(publishTargetComponent.options?.length).to.equal(3); - - const azureOption = publishTargetComponent.options?.find( - (opt) => opt.value === constants.PublishTargets.NEW_AZURE_SERVER, - ); - expect(azureOption).to.exist; - expect(azureOption?.displayName).to.equal("New Azure SQL logical server (Preview)"); - }); - - test("NEW_AZURE_SERVER option hidden when preview features disabled", async () => { - // Arrange - Disable preview features (default in setup) - workspaceConfigStub.withArgs("sqlDatabaseProjects").returns({ - get: sandbox.stub().withArgs("enablePreviewFeatures").returns(false), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - - // Create mock SQL Projects Service that returns AzureV12 target version - const mockSqlProjectsService = { - readProjectProperties: sandbox.stub().resolves({ - targetVersion: "AzureV12", - }), - }; - - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - mockSqlProjectsService as any, // eslint-disable-line @typescript-eslint/no-explicit-any - ); - - // Wait for async initialization to complete - await controller.initialized.promise; - - // Assert - Verify NEW_AZURE_SERVER option does NOT exist - const publishTargetComponent = controller.state.formComponents.publishTarget; - expect(publishTargetComponent).to.exist; - expect(publishTargetComponent.options).to.exist; - expect(publishTargetComponent.options?.length).to.equal(2); - - const azureOption = publishTargetComponent.options?.find( - (opt) => opt.value === constants.PublishTargets.NEW_AZURE_SERVER, - ); - expect(azureOption).to.be.undefined; - }); - - test("server and database fields are visible for all publish targets", async () => { - // Arrange - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - ); - - // Wait for async initialization to complete - await controller.initialized.promise; - - const reducerHandlers = controller["_reducerHandlers"] as Map; - const formAction = reducerHandlers.get("formAction"); - expect(formAction).to.exist; - - // Test EXISTING_SERVER - await formAction(controller.state, { - event: { - propertyName: "publishTarget", - value: constants.PublishTargets.EXISTING_SERVER, - isAction: false, - }, - }); - expect(controller.state.formComponents.serverName?.hidden).to.not.be.true; - expect(controller.state.formComponents.databaseName?.hidden).to.not.be.true; - - // Test LOCAL_CONTAINER - await formAction(controller.state, { - event: { - propertyName: "publishTarget", - value: constants.PublishTargets.LOCAL_CONTAINER, - isAction: false, - }, - }); - expect(controller.state.formComponents.serverName?.hidden).to.be.true; // Hidden for container - expect(controller.state.formComponents.databaseName?.hidden).to.not.be.true; - - // Test NEW_AZURE_SERVER - await formAction(controller.state, { - event: { - propertyName: "publishTarget", - value: constants.PublishTargets.NEW_AZURE_SERVER, - isAction: false, - }, - }); - expect(controller.state.formComponents.serverName?.hidden).to.not.be.true; - expect(controller.state.formComponents.databaseName?.hidden).to.not.be.true; - }); - - test("profile name field works correctly", async () => { - // Arrange - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - ); - - // Wait for async initialization to complete - await controller.initialized.promise; - - const reducerHandlers = controller["_reducerHandlers"] as Map; - const formAction = reducerHandlers.get("formAction"); - expect(formAction).to.exist; - - // Act - Set profile name - await formAction(controller.state, { - event: { - propertyName: "publishProfilePath", - value: "MyPublishProfile", - isAction: false, - }, - }); - - // Assert - expect(controller.state.formState.publishProfilePath).to.equal("MyPublishProfile"); - expect(controller.state.formComponents.publishProfilePath).to.exist; - expect(controller.state.formComponents.publishProfilePath.required).to.be.false; - }); - - test("all form components are properly initialized", async () => { - // Arrange - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - ); - - // Wait for async initialization to complete - await controller.initialized.promise; - - // Assert - Verify all expected form components exist - expect(controller.state.formComponents.publishProfilePath).to.exist; - expect(controller.state.formComponents.serverName).to.exist; - expect(controller.state.formComponents.databaseName).to.exist; - expect(controller.state.formComponents.publishTarget).to.exist; - expect(controller.state.formComponents.containerPort).to.exist; - expect(controller.state.formComponents.containerAdminPassword).to.exist; - expect(controller.state.formComponents.containerAdminPasswordConfirm).to.exist; - expect(controller.state.formComponents.containerImageTag).to.exist; - expect(controller.state.formComponents.acceptContainerLicense).to.exist; - - // Verify required fields - expect(controller.state.formComponents.serverName.required).to.be.true; - expect(controller.state.formComponents.databaseName.required).to.be.true; - - // Verify initial form state - expect(controller.state.formState.publishTarget).to.equal( - constants.PublishTargets.EXISTING_SERVER, - ); - expect(controller.state.projectFilePath).to.equal(projectPath); - }); - - test("field-level validators enforce container and server requirements", async () => { - // Port validation - expect(validateSqlServerPortNumber("1433")).to.be.true; - expect(validateSqlServerPortNumber(1433)).to.be.true; - expect(validateSqlServerPortNumber(""), "empty string invalid").to.be.false; - expect(validateSqlServerPortNumber("0"), "port 0 invalid").to.be.false; - expect(validateSqlServerPortNumber("70000"), "out-of-range port invalid").to.be.false; - - // Password complexity validation - expect(isValidSqlAdminPassword("Password123!"), "complex password valid").to.be.true; - expect(isValidSqlAdminPassword("password"), "simple lowercase invalid").to.be.false; - expect(isValidSqlAdminPassword("PASSWORD"), "simple uppercase invalid").to.be.false; - expect(isValidSqlAdminPassword("Passw0rd"), "missing symbol still ok? need 3 classes").to.be - .true; - - // Password confirm logic (mirrors confirm field validator semantics) - const pwd = "Password123!"; - const confirmOk = pwd === "Password123!"; - const mismatch = "Different" + ""; // widen type to plain string to avoid literal compare lint - const confirmBad = pwd === mismatch; - expect(confirmOk).to.be.true; - expect(confirmBad).to.be.false; - - // License acceptance toggle semantics - const licenseAccepted = true; - const licenseNotAccepted = false; - expect(licenseAccepted).to.be.true; - expect(licenseNotAccepted).to.be.false; - }); - - // UI Visibility Tests - test("updateItemVisibility hides serverName for LOCAL_CONTAINER target", async () => { - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - ); - - await controller.initialized.promise; - - // Set publish target to LOCAL_CONTAINER - controller.state.formState.publishTarget = constants.PublishTargets.LOCAL_CONTAINER; - - await controller.updateItemVisibility(); - - // serverName should be hidden for container deployment - expect(controller.state.formComponents.serverName.hidden).to.be.true; - - // container fields should NOT be hidden - expect(controller.state.formComponents.containerPort?.hidden).to.not.be.true; - expect(controller.state.formComponents.containerAdminPassword?.hidden).to.not.be.true; - }); - - test("updateItemVisibility hides container fields for EXISTING_SERVER target", async () => { - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - ); - - await controller.initialized.promise; - - // Set publish target to EXISTING_SERVER - controller.state.formState.publishTarget = constants.PublishTargets.EXISTING_SERVER; - - await controller.updateItemVisibility(); - - // serverName should NOT be hidden - expect(controller.state.formComponents.serverName.hidden).to.not.be.true; - - // container fields SHOULD be hidden - expect(controller.state.formComponents.containerPort?.hidden).to.be.true; - expect(controller.state.formComponents.containerAdminPassword?.hidden).to.be.true; - expect(controller.state.formComponents.containerAdminPasswordConfirm?.hidden).to.be.true; - expect(controller.state.formComponents.containerImageTag?.hidden).to.be.true; - expect(controller.state.formComponents.acceptContainerLicense?.hidden).to.be.true; - }); - - test("updateItemVisibility hides container fields for NEW_AZURE_SERVER target", async () => { - const controller = new PublishProjectWebViewController( - mockContext, - mockVscodeWrapper, - projectPath, - ); - - await controller.initialized.promise; - - // Set publish target to NEW_AZURE_SERVER - controller.state.formState.publishTarget = constants.PublishTargets.NEW_AZURE_SERVER; - - await controller.updateItemVisibility(); - - // serverName should NOT be hidden - expect(controller.state.formComponents.serverName.hidden).to.not.be.true; - - // container fields SHOULD be hidden - expect(controller.state.formComponents.containerPort?.hidden).to.be.true; - expect(controller.state.formComponents.containerAdminPassword?.hidden).to.be.true; - expect(controller.state.formComponents.containerAdminPasswordConfirm?.hidden).to.be.true; - expect(controller.state.formComponents.containerImageTag?.hidden).to.be.true; - expect(controller.state.formComponents.acceptContainerLicense?.hidden).to.be.true; - }); -}); diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index ecadbbd64a..63f3763c96 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -10,6 +10,10 @@ import * as constants from "../../src/constants/constants"; import VscodeWrapper from "../../src/controllers/vscodeWrapper"; import { PublishProjectWebViewController } from "../../src/publishProject/publishProjectWebViewController"; +import { + validateSqlServerPortNumber, + isValidSqlAdminPassword, +} from "../../src/publishProject/projectUtils"; import { stubVscodeWrapper } from "./utils"; suite("PublishProjectWebViewController Tests", () => { @@ -172,4 +176,154 @@ suite("PublishProjectWebViewController Tests", () => { controller.state.inProgress = true; expect(controller.state.inProgress).to.be.true; }); + + test("container target values are properly saved to formState", async () => { + const projectPath = "c:/work/ContainerProject.sqlproj"; + const controller = new PublishProjectWebViewController( + contextStub, + vscodeWrapperStub, + projectPath, + ); + + await controller.initialized.promise; + + const reducerHandlers = controller["_reducerHandlers"] as Map; + const formAction = reducerHandlers.get("formAction"); + expect(formAction, "formAction reducer should be registered").to.exist; + + // Set target to localContainer + await formAction(controller.state, { + event: { + propertyName: "publishTarget", + value: constants.PublishTargets.LOCAL_CONTAINER, + isAction: false, + }, + }); + + // Set container-specific values + await formAction(controller.state, { + event: { propertyName: "containerPort", value: "1434", isAction: false }, + }); + await formAction(controller.state, { + event: { + propertyName: "containerAdminPassword", + value: "TestPassword123!", + isAction: false, + }, + }); + await formAction(controller.state, { + event: { + propertyName: "containerAdminPasswordConfirm", + value: "TestPassword123!", + isAction: false, + }, + }); + await formAction(controller.state, { + event: { propertyName: "containerImageTag", value: "2022-latest", isAction: false }, + }); + await formAction(controller.state, { + event: { propertyName: "acceptContainerLicense", value: true, isAction: false }, + }); + + // Verify all values are saved + expect(controller.state.formState.publishTarget).to.equal( + constants.PublishTargets.LOCAL_CONTAINER, + ); + expect(controller.state.formState.containerPort).to.equal("1434"); + expect(controller.state.formState.containerAdminPassword).to.equal("TestPassword123!"); + expect(controller.state.formState.containerAdminPasswordConfirm).to.equal( + "TestPassword123!", + ); + expect(controller.state.formState.containerImageTag).to.equal("2022-latest"); + expect(controller.state.formState.acceptContainerLicense).to.equal(true); + + // Verify container fields are visible + expect(controller.state.formComponents.containerPort?.hidden).to.not.be.true; + expect(controller.state.formComponents.containerAdminPassword?.hidden).to.not.be.true; + }); + + test("Azure SQL project shows Azure-specific labels", async () => { + const mockSqlProjectsService = { + getProjectProperties: sandbox.stub().resolves({ + success: true, + projectGuid: "test-guid", + databaseSchemaProvider: + "Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider", + }), + }; + + const controller = new PublishProjectWebViewController( + contextStub, + vscodeWrapperStub, + "c:/work/AzureProject.sqlproj", + mockSqlProjectsService as any, // eslint-disable-line @typescript-eslint/no-explicit-any + ); + + await controller.initialized.promise; + + const publishTargetComponent = controller.state.formComponents.publishTarget; + const existingServerOption = publishTargetComponent.options?.find( + (opt) => opt.value === constants.PublishTargets.EXISTING_SERVER, + ); + const containerOption = publishTargetComponent.options?.find( + (opt) => opt.value === constants.PublishTargets.LOCAL_CONTAINER, + ); + + expect(existingServerOption?.displayName).to.equal("Existing Azure SQL logical server"); + expect(containerOption?.displayName).to.equal("New SQL Server local development container"); + }); + + test("NEW_AZURE_SERVER option appears with preview features enabled for Azure SQL", async () => { + // Enable preview features + const configStub = sandbox.stub(vscode.workspace, "getConfiguration"); + configStub.withArgs("sqlDatabaseProjects").returns({ + get: sandbox.stub().withArgs("enablePreviewFeatures").returns(true), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const mockSqlProjectsService = { + getProjectProperties: sandbox.stub().resolves({ + success: true, + projectGuid: "test-guid", + databaseSchemaProvider: + "Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider", + }), + }; + + const controller = new PublishProjectWebViewController( + contextStub, + vscodeWrapperStub, + "c:/work/AzureProject.sqlproj", + mockSqlProjectsService as any, // eslint-disable-line @typescript-eslint/no-explicit-any + ); + + await controller.initialized.promise; + + const publishTargetComponent = controller.state.formComponents.publishTarget; + expect(publishTargetComponent.options?.length).to.equal(3); + + const azureOption = publishTargetComponent.options?.find( + (opt) => opt.value === constants.PublishTargets.NEW_AZURE_SERVER, + ); + expect(azureOption).to.exist; + expect(azureOption?.displayName).to.equal("New Azure SQL logical server (Preview)"); + }); + + test("field validators enforce container and server requirements", () => { + // Port validation + expect(validateSqlServerPortNumber("1433")).to.be.true; + expect(validateSqlServerPortNumber(1433)).to.be.true; + expect(validateSqlServerPortNumber(""), "empty string invalid").to.be.false; + expect(validateSqlServerPortNumber("0"), "port 0 invalid").to.be.false; + expect(validateSqlServerPortNumber("70000"), "out-of-range port invalid").to.be.false; + expect(validateSqlServerPortNumber("abc"), "non-numeric invalid").to.be.false; + + // Password complexity validation (8-128 chars, 3 of 4: upper, lower, digit, symbol) + expect(isValidSqlAdminPassword("Password123!"), "complex password valid").to.be.true; + expect(isValidSqlAdminPassword("Passw0rd"), "3 categories valid").to.be.true; + expect(isValidSqlAdminPassword("password"), "simple lowercase invalid").to.be.false; + expect(isValidSqlAdminPassword("PASSWORD"), "simple uppercase invalid").to.be.false; + expect(isValidSqlAdminPassword("Pass1"), "too short invalid").to.be.false; + expect(isValidSqlAdminPassword("Password123!".repeat(20)), "too long invalid").to.be.false; + }); }); From 83e4b60b10c9d5a378514f8232bfdbf2a136dd90 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 7 Oct 2025 23:00:11 -0500 Subject: [PATCH 39/94] renaming profileName to publishprofileName for clear understanding --- src/sharedInterfaces/publishDialog.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 07d573c34d..4b2aa918fb 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -81,10 +81,10 @@ export interface PublishProjectProvider { sqlCmdVariables?: { [key: string]: string }; publishProfilePath?: string; }): void; - /** Generate (but do not execute) a publish script */ + /** Generate a publish script */ generatePublishScript(): void; - /** Choose a publish profile file and apply (may partially override form state) */ + /** Choose a publish profile file and apply */ selectPublishProfile(): void; - /** Persist current form state as a named profile */ - savePublishProfile(profileName: string): void; + /** Persist current form state as a named publish profile */ + savePublishProfile(publishProfileName: string): void; } From 1007b90f5caaecf1efe7875d9569bc022c97984f Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 7 Oct 2025 23:59:47 -0500 Subject: [PATCH 40/94] refactoring for options --- src/constants/constants.ts | 1 + src/publishProject/formComponentHelpers.ts | 24 ++++++++++++------- src/publishProject/projectUtils.ts | 13 ---------- .../publishProjectWebViewController.ts | 2 +- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/constants/constants.ts b/src/constants/constants.ts index bf6a01a5b5..2888e2b49e 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -317,6 +317,7 @@ export const DefaultAdminUsername = "sa"; export const LicenseAcceptanceMessage = "You must accept the license"; export const DBProjectConfigurationKey = "sqlDatabaseProjects"; export const enablePreviewFeaturesKey = "enablePreviewFeatures"; +export const AzureSqlV12 = "AzureV12"; export const PublishTargets = { EXISTING_SERVER: "existingServer" as const, diff --git a/src/publishProject/formComponentHelpers.ts b/src/publishProject/formComponentHelpers.ts index bfca91bc91..2a3980855f 100644 --- a/src/publishProject/formComponentHelpers.ts +++ b/src/publishProject/formComponentHelpers.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as vscode from "vscode"; import * as constants from "../constants/constants"; import { FormItemType, FormItemOptions } from "../sharedInterfaces/form"; import { PublishProject as Loc } from "../constants/locConstants"; @@ -11,15 +12,21 @@ import { PublishDialogFormItemSpec, PublishDialogState, } from "../sharedInterfaces/publishDialog"; -import { - getPublishServerName, - isPreviewFeaturesEnabled, - SqlTargetPlatform, - targetPlatformToVersion, - validateSqlServerPortNumber, -} from "./projectUtils"; +import { getPublishServerName, validateSqlServerPortNumber } from "./projectUtils"; import { validateSqlServerPassword } from "../deployment/dockerUtils"; +/** + * Checks if preview features are enabled in VS Code settings for SQL Database Projects. + * @returns true if preview features are enabled, false otherwise + */ +function isPreviewFeaturesEnabled(): boolean { + return ( + vscode.workspace + .getConfiguration(constants.DBProjectConfigurationKey) + .get(constants.enablePreviewFeaturesKey) ?? false + ); +} + /** * Generate publish target options based on project target version * @param projectTargetVersion - The target version of the project (e.g., "AzureV12" for Azure SQL) @@ -27,8 +34,7 @@ import { validateSqlServerPassword } from "../deployment/dockerUtils"; */ function generatePublishTargetOptions(projectTargetVersion?: string): FormItemOptions[] { // Check if this is an Azure SQL project - const isAzureSqlProject = - projectTargetVersion === targetPlatformToVersion[SqlTargetPlatform.sqlAzure]; + const isAzureSqlProject = projectTargetVersion === constants.AzureSqlV12; const options: FormItemOptions[] = [ { displayName: isAzureSqlProject diff --git a/src/publishProject/projectUtils.ts b/src/publishProject/projectUtils.ts index fcfdcc4b2c..5736620645 100644 --- a/src/publishProject/projectUtils.ts +++ b/src/publishProject/projectUtils.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as vscode from "vscode"; import * as mssql from "vscode-mssql"; import * as constants from "../constants/constants"; import { SqlProjectsService } from "../services/sqlProjectsService"; @@ -131,18 +130,6 @@ export function getPublishServerName(target: string) { : constants.SqlServerName; } -/** - * Checks if preview features are enabled in VS Code settings for SQL Database Projects. - * @returns true if preview features are enabled, false otherwise - */ -export function isPreviewFeaturesEnabled(): boolean { - return ( - vscode.workspace - .getConfiguration(constants.DBProjectConfigurationKey) - .get(constants.enablePreviewFeaturesKey) ?? false - ); -} - /* * Validates the SQL Server port number. */ diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 8fbf552a6a..d7858eb959 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -49,7 +49,7 @@ export class PublishProjectWebViewController extends FormWebviewController< publishTarget: "existingServer", sqlCmdVariables: {}, }, - formComponents: generatePublishFormComponents(), + formComponents: {}, projectFilePath, inProgress: false, lastPublishResult: undefined, From 728ae1458072ded879ad1e6f9589404620002ce5 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 8 Oct 2025 00:22:19 -0500 Subject: [PATCH 41/94] intial commit to add select publish profile reducer that opens and selects the profile and updates the state --- src/constants/locConstants.ts | 3 ++ .../publishProjectWebViewController.ts | 30 ++++++++++++++++++- .../components/FormFieldComponents.tsx | 2 ++ .../components/PublishProfileSection.tsx | 2 +- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index 1a520e9a1d..30ebd49505 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1284,6 +1284,9 @@ export class PublishProject { public static Title = l10n.t("Publish Project"); public static PublishProfileLabel = l10n.t("Publish Profile"); public static PublishProfilePlaceholder = l10n.t("Select or enter a publish profile"); + public static SelectPublishProfile = l10n.t("Select Profile"); + public static SaveAs = l10n.t("Save As"); + public static PublishProfileFiles = l10n.t("Publish Profile Files"); public static ServerLabel = l10n.t("Server"); public static DatabaseLabel = l10n.t("Database"); public static DatabaseRequiredMessage = l10n.t("Database name is required"); diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index d7858eb959..25e9814425 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -148,7 +148,35 @@ export class PublishProjectWebViewController extends FormWebviewController< }); this.registerReducer("selectPublishProfile", async (state: PublishDialogState) => { - // TODO: implement profile selection logic + // Open file browser to select a .publish.xml file + const projectFolderPath = state.projectFilePath + ? path.dirname(state.projectFilePath) + : undefined; + + // Open browse dialog to select the publish.xml file + const fileUris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + defaultUri: projectFolderPath ? vscode.Uri.file(projectFolderPath) : undefined, + openLabel: Loc.SelectPublishProfile, + filters: { + [Loc.PublishProfileFiles]: ["publish.xml"], + }, + }); + + if (fileUris && fileUris.length > 0) { + const selectedPath = fileUris[0].fsPath; + // Update the publishProfilePath in form state + return { + ...state, + formState: { + ...state.formState, + publishProfilePath: selectedPath, + }, + }; + } + return state; }); diff --git a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx index a402fb8cff..d4c3e88fef 100644 --- a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx +++ b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx @@ -24,6 +24,7 @@ export const renderInput = ( onBlur?: (value: string) => void; showPassword?: boolean; onTogglePassword?: () => void; + readOnly?: boolean; }, ) => { if (!component || component.hidden) return undefined; @@ -47,6 +48,7 @@ export const renderInput = ( value={value} placeholder={component.placeholder ?? ""} required={component.required} + readOnly={options?.readOnly} onChange={(_, data) => onChange(data.value)} onBlur={() => options?.onBlur?.(value)} contentAfter={ diff --git a/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx index 74fc09dcda..930b5987c8 100644 --- a/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx @@ -57,7 +57,7 @@ export const PublishProfileField: React.FC = () => { return (
- {renderInput(component, localValue, setLocalValue)} + {renderInput(component, localValue, setLocalValue, { readOnly: true })}
diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 4b2aa918fb..4aded177f6 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -4,8 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import * as constants from "../constants/constants"; +import * as mssql from "vscode-mssql"; import { FormItemSpec, FormState, FormReducers, FormEvent } from "./form"; +/** + * Project Properties interface -shared between server-side code and browser-side webviews. + */ +export interface ProjectProperties { + projectGuid?: string; + configuration?: string; + outputPath: string; + databaseSource?: string; + defaultCollation: string; + databaseSchemaProvider: string; + projectStyle: unknown; + targetVersion?: string; + projectName?: string; + projectFolderPath?: string; +} + /** * Data fields shown in the Publish form. */ @@ -31,12 +48,8 @@ export interface PublishDialogState projectFilePath: string; inProgress: boolean; lastPublishResult?: { success: boolean; details?: string }; - // Optional project metadata (target version, etc.) loaded asynchronously - projectProperties?: { - targetVersion?: string; - // Additional properties can be added here as needed - [key: string]: unknown; - }; + deploymentOptions?: mssql.DeploymentOptions; + projectProperties?: ProjectProperties; } /** From bbb150003390e8bc27d0d0502b01ba54f9a8e412 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 8 Oct 2025 13:13:29 -0500 Subject: [PATCH 44/94] format updates and save button logic update --- src/publishProject/publishProjectWebViewController.ts | 11 ++--------- .../components/PublishProfileSection.tsx | 4 ++-- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 3ff27d1d60..63ebd5afe9 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -228,20 +228,13 @@ export class PublishProjectWebViewController extends FormWebviewController< void vscode.window.showInformationMessage( `Publish profile saved to: ${fileUri.fsPath}`, ); - - return { - ...state, - formState: { - ...state.formState, - publishProfilePath: fileUri.fsPath, - }, - }; } catch (error) { void vscode.window.showErrorMessage( `Failed to save publish profile: ${error}`, ); - return state; } + + return state; } // If DacFx service is not available, just update the path diff --git a/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx index e8299d0a89..41929ee226 100644 --- a/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx @@ -25,7 +25,7 @@ const useStyles = makeStyles({ buttons: { display: "flex", flexDirection: "row", - gap: "4px", + gap: "8px", paddingBottom: "4px", alignSelf: "flex-end", }, @@ -55,7 +55,7 @@ export const PublishProfileField: React.FC = () => { } return ( -
+
{renderInput(component, localValue, setLocalValue, { readOnly: true })}
From 2f20abd17b11ad1bb5939792b6b48f9107ec6a0d Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 8 Oct 2025 22:24:48 -0500 Subject: [PATCH 45/94] adding tests for select and save profile --- .../components/PublishProfileSection.tsx | 2 - .../publishProjectWebViewController.test.ts | 64 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx index 41929ee226..0a8b2ab7b3 100644 --- a/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishProfileSection.tsx @@ -5,7 +5,6 @@ import { Button, makeStyles } from "@fluentui/react-components"; import { useContext, useState, useEffect } from "react"; -import { useFormStyles } from "../../../common/forms/form.component"; import { LocConstants } from "../../../common/locConstants"; import { PublishProjectContext } from "../publishProjectStateProvider"; import { usePublishDialogSelector } from "../publishDialogSelector"; @@ -38,7 +37,6 @@ const useStyles = makeStyles({ // Publish profile name input with action buttons (select & save) rendered inline via selectors. export const PublishProfileField: React.FC = () => { const classes = useStyles(); - const formStyles = useFormStyles(); const loc = LocConstants.getInstance().publishProject; const context = useContext(PublishProjectContext) as PublishProjectProvider | undefined; const component = usePublishDialogSelector((s) => s.formComponents.publishProfilePath); diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 63f3763c96..4e3b17ad59 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -326,4 +326,68 @@ suite("PublishProjectWebViewController Tests", () => { expect(isValidSqlAdminPassword("Pass1"), "too short invalid").to.be.false; expect(isValidSqlAdminPassword("Password123!".repeat(20)), "too long invalid").to.be.false; }); + + test("selectPublishProfile reducer is invoked and triggers file picker", async () => { + const projectPath = "c:/work/TestProject.sqlproj"; + const controller = new PublishProjectWebViewController( + contextStub, + vscodeWrapperStub, + projectPath, + ); + + await controller.initialized.promise; + + // Stub showOpenDialog to simulate user selecting a profile + const selectedProfilePath = "c:/profiles/MyProfile.publish.xml"; + const showOpenDialogStub = sandbox + .stub(vscode.window, "showOpenDialog") + .resolves([vscode.Uri.file(selectedProfilePath)]); + + const reducerHandlers = controller["_reducerHandlers"] as Map; + const selectPublishProfile = reducerHandlers.get("selectPublishProfile"); + expect(selectPublishProfile, "selectPublishProfile reducer should be registered").to.exist; + + // Invoke the reducer + await selectPublishProfile(controller.state, {}); + + // Verify file picker was shown + expect(showOpenDialogStub.calledOnce, "showOpenDialog should be called once").to.be.true; + + // Verify the profile path was updated in formState + expect(controller.state.formState.publishProfilePath).to.equal(selectedProfilePath); + }); + + test("savePublishProfile reducer is invoked and triggers save file dialog", async () => { + const projectPath = "c:/work/TestProject.sqlproj"; + const controller = new PublishProjectWebViewController( + contextStub, + vscodeWrapperStub, + projectPath, + ); + + await controller.initialized.promise; + + // Set up some form state to save + controller.state.formState.serverName = "localhost"; + controller.state.formState.databaseName = "TestDB"; + + // Stub showSaveDialog to simulate user choosing a save location + const savedProfilePath = "c:/profiles/NewProfile.publish.xml"; + const showSaveDialogStub = sandbox + .stub(vscode.window, "showSaveDialog") + .resolves(vscode.Uri.file(savedProfilePath)); + + const reducerHandlers = controller["_reducerHandlers"] as Map; + const savePublishProfile = reducerHandlers.get("savePublishProfile"); + expect(savePublishProfile, "savePublishProfile reducer should be registered").to.exist; + + // Invoke the reducer with an optional default filename + await savePublishProfile(controller.state, { event: "TestProject.publish.xml" }); + + // Verify save dialog was shown + expect(showSaveDialogStub.calledOnce, "showSaveDialog should be called once").to.be.true; + + // Verify the saved profile path was updated in formState + expect(controller.state.formState.publishProfilePath).to.equal(savedProfilePath); + }); }); From 95e62d68a536217b797c10b0502526fbc378cedf Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 9 Oct 2025 13:13:36 -0500 Subject: [PATCH 46/94] Using required constants throught the shared interfaces. --- src/constants/constants.ts | 10 ----- src/publishProject/formComponentHelpers.ts | 7 ++-- .../publishProjectWebViewController.ts | 9 ++-- .../components/PublishTargetSection.tsx | 42 ++++++++++--------- .../pages/PublishProject/publishProject.tsx | 6 +-- src/sharedInterfaces/publishDialog.ts | 13 +++++- .../publishProjectWebViewController.test.ts | 22 ++++------ 7 files changed, 54 insertions(+), 55 deletions(-) diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 2888e2b49e..b8c8f096d4 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -319,12 +319,6 @@ export const DBProjectConfigurationKey = "sqlDatabaseProjects"; export const enablePreviewFeaturesKey = "enablePreviewFeatures"; export const AzureSqlV12 = "AzureV12"; -export const PublishTargets = { - EXISTING_SERVER: "existingServer" as const, - LOCAL_CONTAINER: "localContainer" as const, - NEW_AZURE_SERVER: "newAzureServer" as const, -} as const; -export type PublishTargetType = (typeof PublishTargets)[keyof typeof PublishTargets]; export const PublishFormFields = { PublishProfilePath: "publishProfilePath", ServerName: "serverName", @@ -337,10 +331,6 @@ export const PublishFormFields = { ContainerImageTag: "containerImageTag", AcceptContainerLicense: "acceptContainerLicense", } as const; - -// Group of all container-specific publish form field identifiers used together when -// the target is a local container. Centralizing this list avoids duplication in -// controllers (e.g., building active component arrays and computing hidden fields). export const PublishFormContainerFields = [ PublishFormFields.ContainerPort, PublishFormFields.ContainerAdminPassword, diff --git a/src/publishProject/formComponentHelpers.ts b/src/publishProject/formComponentHelpers.ts index 2a3980855f..5931068a9d 100644 --- a/src/publishProject/formComponentHelpers.ts +++ b/src/publishProject/formComponentHelpers.ts @@ -11,6 +11,7 @@ import { IPublishForm, PublishDialogFormItemSpec, PublishDialogState, + PublishTarget, } from "../sharedInterfaces/publishDialog"; import { getPublishServerName, validateSqlServerPortNumber } from "./projectUtils"; import { validateSqlServerPassword } from "../deployment/dockerUtils"; @@ -40,13 +41,13 @@ function generatePublishTargetOptions(projectTargetVersion?: string): FormItemOp displayName: isAzureSqlProject ? Loc.PublishTargetExistingLogical : Loc.PublishTargetExisting, - value: constants.PublishTargets.EXISTING_SERVER, + value: PublishTarget.ExistingServer, }, { displayName: isAzureSqlProject ? Loc.PublishTargetAzureEmulator : Loc.PublishTargetContainer, - value: constants.PublishTargets.LOCAL_CONTAINER, + value: PublishTarget.LocalContainer, }, ]; if (isAzureSqlProject) { @@ -54,7 +55,7 @@ function generatePublishTargetOptions(projectTargetVersion?: string): FormItemOp if (isPreviewFeaturesEnabled()) { options.push({ displayName: Loc.PublishTargetNewAzureServer, - value: constants.PublishTargets.NEW_AZURE_SERVER, + value: PublishTarget.NewAzureServer, }); } } diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index d7858eb959..a4de1629e0 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -14,6 +14,7 @@ import { PublishDialogFormItemSpec, IPublishForm, PublishDialogState, + PublishTarget, } from "../sharedInterfaces/publishDialog"; import { generatePublishFormComponents } from "./formComponentHelpers"; import { loadDockerTags } from "./dockerUtils"; @@ -170,7 +171,7 @@ export class PublishProjectWebViewController extends FormWebviewController< constants.PublishFormFields.DatabaseName, ] as (keyof IPublishForm)[]; - if (state.formState.publishTarget === constants.PublishTargets.LOCAL_CONTAINER) { + if (state.formState.publishTarget === PublishTarget.LocalContainer) { activeComponents.push(...constants.PublishFormContainerFields); } @@ -182,12 +183,12 @@ export class PublishProjectWebViewController extends FormWebviewController< const target = currentState.formState?.publishTarget; const hidden: string[] = []; - if (target === constants.PublishTargets.LOCAL_CONTAINER) { + if (target === PublishTarget.LocalContainer) { // Container deployment: hide server name field hidden.push(constants.PublishFormFields.ServerName); } else if ( - target === constants.PublishTargets.EXISTING_SERVER || - target === constants.PublishTargets.NEW_AZURE_SERVER + target === PublishTarget.ExistingServer || + target === PublishTarget.NewAzureServer ) { // Existing server or new Azure server: hide container-specific fields hidden.push(...constants.PublishFormContainerFields); diff --git a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx index 1dd4a6e730..b6023cb486 100644 --- a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx @@ -7,7 +7,11 @@ import { useContext, useEffect, useState } from "react"; import { makeStyles } from "@fluentui/react-components"; import { PublishProjectContext } from "../publishProjectStateProvider"; import { usePublishDialogSelector } from "../publishDialogSelector"; -import * as constants from "../../../../constants/constants"; +import { + PublishTarget, + PublishFormFields, + DefaultSqlPortNumber, +} from "../../../../sharedInterfaces/publishDialog"; import { renderInput, renderDropdown, renderCheckbox } from "./FormFieldComponents"; import { parseHtmlLabel } from "../../../common/utils"; @@ -34,48 +38,46 @@ export const PublishTargetSection: React.FC = () => { // Select form components and values - components needed for rendering, values for logic const targetComponent = usePublishDialogSelector( - (s) => s.formComponents[constants.PublishFormFields.PublishTarget], + (s) => s.formComponents[PublishFormFields.PublishTarget], ); const targetValue = usePublishDialogSelector( - (s) => s.formState[constants.PublishFormFields.PublishTarget], + (s) => s.formState[PublishFormFields.PublishTarget], ); - const isContainer = targetValue === constants.PublishTargets.LOCAL_CONTAINER; + const isContainer = targetValue === PublishTarget.LocalContainer; // Container-specific fields (only select when needed) const portComponent = usePublishDialogSelector( - (s) => s.formComponents[constants.PublishFormFields.ContainerPort], - ); - const portValue = usePublishDialogSelector( - (s) => s.formState[constants.PublishFormFields.ContainerPort], + (s) => s.formComponents[PublishFormFields.ContainerPort], ); + const portValue = usePublishDialogSelector((s) => s.formState[PublishFormFields.ContainerPort]); const passwordComponent = usePublishDialogSelector( - (s) => s.formComponents[constants.PublishFormFields.ContainerAdminPassword], + (s) => s.formComponents[PublishFormFields.ContainerAdminPassword], ); const passwordValue = usePublishDialogSelector( - (s) => s.formState[constants.PublishFormFields.ContainerAdminPassword], + (s) => s.formState[PublishFormFields.ContainerAdminPassword], ); const confirmPasswordComponent = usePublishDialogSelector( - (s) => s.formComponents[constants.PublishFormFields.ContainerAdminPasswordConfirm], + (s) => s.formComponents[PublishFormFields.ContainerAdminPasswordConfirm], ); const confirmPasswordValue = usePublishDialogSelector( - (s) => s.formState[constants.PublishFormFields.ContainerAdminPasswordConfirm], + (s) => s.formState[PublishFormFields.ContainerAdminPasswordConfirm], ); const imageTagComponent = usePublishDialogSelector( - (s) => s.formComponents[constants.PublishFormFields.ContainerImageTag], + (s) => s.formComponents[PublishFormFields.ContainerImageTag], ); const imageTagValue = usePublishDialogSelector( - (s) => s.formState[constants.PublishFormFields.ContainerImageTag], + (s) => s.formState[PublishFormFields.ContainerImageTag], ); const licenseComponent = usePublishDialogSelector( - (s) => s.formComponents[constants.PublishFormFields.AcceptContainerLicense], + (s) => s.formComponents[PublishFormFields.AcceptContainerLicense], ); const licenseValue = usePublishDialogSelector( - (s) => s.formState[constants.PublishFormFields.AcceptContainerLicense], + (s) => s.formState[PublishFormFields.AcceptContainerLicense], ); // Password visibility state management @@ -106,9 +108,9 @@ export const PublishTargetSection: React.FC = () => { // Default container port if not set if (!portValue) { publishCtx.formAction({ - propertyName: constants.PublishFormFields.ContainerPort, + propertyName: PublishFormFields.ContainerPort, isAction: false, - value: constants.DefaultSqlPortNumber, + value: DefaultSqlPortNumber, updateValidation: true, }); } @@ -116,7 +118,7 @@ export const PublishTargetSection: React.FC = () => { // Auto-select first image tag if not set if (!imageTagValue && imageTagComponent?.options?.[0]) { publishCtx.formAction({ - propertyName: constants.PublishFormFields.ContainerImageTag, + propertyName: PublishFormFields.ContainerImageTag, isAction: false, value: imageTagComponent.options[0].value, updateValidation: true, @@ -133,7 +135,7 @@ export const PublishTargetSection: React.FC = () => { // Only revalidate if confirm password field has a value if (confirmPasswordValue !== undefined && confirmPasswordValue !== "") { publishCtx.formAction({ - propertyName: constants.PublishFormFields.ContainerAdminPasswordConfirm, + propertyName: PublishFormFields.ContainerAdminPasswordConfirm, isAction: false, value: confirmPasswordValue as string, updateValidation: true, diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index 5fdaf7d2a3..ac17cadff1 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -7,13 +7,12 @@ import { useContext } from "react"; import { Button, makeStyles } from "@fluentui/react-components"; import { useFormStyles } from "../../common/forms/form.component"; import { PublishProjectContext } from "./publishProjectStateProvider"; -import { IPublishForm } from "../../../sharedInterfaces/publishDialog"; +import { IPublishForm, PublishTarget } from "../../../sharedInterfaces/publishDialog"; import { usePublishDialogSelector } from "./publishDialogSelector"; import { LocConstants } from "../../common/locConstants"; import { PublishProfileField } from "./components/PublishProfileSection"; import { PublishTargetSection } from "./components/PublishTargetSection"; import { ConnectionSection } from "./components/ConnectionSection"; -import * as constants from "../../../constants/constants"; const useStyles = makeStyles({ root: { padding: "12px" }, @@ -101,8 +100,7 @@ function PublishProjectDialog() { appearance="secondary" disabled={ readyToPublish || - formState?.publishTarget !== - constants.PublishTargets.EXISTING_SERVER + formState?.publishTarget !== PublishTarget.ExistingServer } onClick={() => context.generatePublishScript()}> {loc.generateScript} diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 4b2aa918fb..3fb20730dc 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -6,6 +6,17 @@ import * as constants from "../constants/constants"; import { FormItemSpec, FormState, FormReducers, FormEvent } from "./form"; +// Publish target options - defines where the database project will be published +export enum PublishTarget { + ExistingServer = "existingServer", + LocalContainer = "localContainer", + NewAzureServer = "newAzureServer", +} + +// export publish-related constants for use in webview code +export const PublishFormFields = constants.PublishFormFields; +export const DefaultSqlPortNumber = constants.DefaultSqlPortNumber; + /** * Data fields shown in the Publish form. */ @@ -13,7 +24,7 @@ export interface IPublishForm { publishProfilePath?: string; serverName?: string; databaseName?: string; - publishTarget?: constants.PublishTargetType; + publishTarget?: PublishTarget; sqlCmdVariables?: { [key: string]: string }; // Container deployment specific fields (only used when publishTarget === 'localContainer') containerPort?: string; diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 63f3763c96..64b8397400 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -6,7 +6,6 @@ import * as vscode from "vscode"; import { expect } from "chai"; import * as sinon from "sinon"; -import * as constants from "../../src/constants/constants"; import VscodeWrapper from "../../src/controllers/vscodeWrapper"; import { PublishProjectWebViewController } from "../../src/publishProject/publishProjectWebViewController"; @@ -15,6 +14,7 @@ import { isValidSqlAdminPassword, } from "../../src/publishProject/projectUtils"; import { stubVscodeWrapper } from "./utils"; +import { PublishTarget } from "../../src/sharedInterfaces/publishDialog"; suite("PublishProjectWebViewController Tests", () => { let sandbox: sinon.SinonSandbox; @@ -99,9 +99,7 @@ suite("PublishProjectWebViewController Tests", () => { await controller.initialized.promise; - expect(controller.state.formState.publishTarget).to.equal( - constants.PublishTargets.EXISTING_SERVER, - ); + expect(controller.state.formState.publishTarget).to.equal(PublishTarget.ExistingServer); }); test("getActiveFormComponents returns correct fields for EXISTING_SERVER target", async () => { @@ -115,7 +113,7 @@ suite("PublishProjectWebViewController Tests", () => { await controller.initialized.promise; // Set publish target to EXISTING_SERVER (default) - controller.state.formState.publishTarget = constants.PublishTargets.EXISTING_SERVER; + controller.state.formState.publishTarget = PublishTarget.ExistingServer; const activeComponents = controller["getActiveFormComponents"](controller.state); @@ -141,7 +139,7 @@ suite("PublishProjectWebViewController Tests", () => { await controller.initialized.promise; // Set publish target to LOCAL_CONTAINER - controller.state.formState.publishTarget = constants.PublishTargets.LOCAL_CONTAINER; + controller.state.formState.publishTarget = PublishTarget.LocalContainer; const activeComponents = controller["getActiveFormComponents"](controller.state); @@ -195,7 +193,7 @@ suite("PublishProjectWebViewController Tests", () => { await formAction(controller.state, { event: { propertyName: "publishTarget", - value: constants.PublishTargets.LOCAL_CONTAINER, + value: PublishTarget.LocalContainer, isAction: false, }, }); @@ -226,9 +224,7 @@ suite("PublishProjectWebViewController Tests", () => { }); // Verify all values are saved - expect(controller.state.formState.publishTarget).to.equal( - constants.PublishTargets.LOCAL_CONTAINER, - ); + expect(controller.state.formState.publishTarget).to.equal(PublishTarget.LocalContainer); expect(controller.state.formState.containerPort).to.equal("1434"); expect(controller.state.formState.containerAdminPassword).to.equal("TestPassword123!"); expect(controller.state.formState.containerAdminPasswordConfirm).to.equal( @@ -263,10 +259,10 @@ suite("PublishProjectWebViewController Tests", () => { const publishTargetComponent = controller.state.formComponents.publishTarget; const existingServerOption = publishTargetComponent.options?.find( - (opt) => opt.value === constants.PublishTargets.EXISTING_SERVER, + (opt) => opt.value === PublishTarget.ExistingServer, ); const containerOption = publishTargetComponent.options?.find( - (opt) => opt.value === constants.PublishTargets.LOCAL_CONTAINER, + (opt) => opt.value === PublishTarget.LocalContainer, ); expect(existingServerOption?.displayName).to.equal("Existing Azure SQL logical server"); @@ -303,7 +299,7 @@ suite("PublishProjectWebViewController Tests", () => { expect(publishTargetComponent.options?.length).to.equal(3); const azureOption = publishTargetComponent.options?.find( - (opt) => opt.value === constants.PublishTargets.NEW_AZURE_SERVER, + (opt) => opt.value === PublishTarget.NewAzureServer, ); expect(azureOption).to.exist; expect(azureOption?.displayName).to.equal("New Azure SQL logical server (Preview)"); From 39e5d455c2c53d4e70fdd25a1ed452f06014a7ab Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 9 Oct 2025 13:49:02 -0500 Subject: [PATCH 47/94] adding telemetry --- src/publishProject/publishProjectWebViewController.ts | 9 +++++++++ src/sharedInterfaces/telemetry.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index e1269432db..92b1209d4e 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -17,6 +17,8 @@ import { PublishDialogState, PublishTarget, } from "../sharedInterfaces/publishDialog"; +import { TelemetryActions, TelemetryViews } from "../sharedInterfaces/telemetry"; +import { sendActionEvent } from "../telemetry/telemetry"; import { generatePublishFormComponents } from "./formComponentHelpers"; import { loadDockerTags } from "./dockerUtils"; import { readProjectProperties } from "./projectUtils"; @@ -166,6 +168,10 @@ export class PublishProjectWebViewController extends FormWebviewController< if (fileUris && fileUris.length > 0) { const selectedPath = fileUris[0].fsPath; + + // Send telemetry for profile loaded + sendActionEvent(TelemetryViews.SqlProjects, TelemetryActions.profileLoaded); + // Update the publishProfilePath in form state return { ...state, @@ -226,6 +232,9 @@ export class PublishProjectWebViewController extends FormWebviewController< state.deploymentOptions, ); + // Send telemetry for profile saved + sendActionEvent(TelemetryViews.SqlProjects, TelemetryActions.profileSaved); + void vscode.window.showInformationMessage( `Publish profile saved to: ${fileUri.fsPath}`, ); diff --git a/src/sharedInterfaces/telemetry.ts b/src/sharedInterfaces/telemetry.ts index 91567f7983..eae28daa19 100644 --- a/src/sharedInterfaces/telemetry.ts +++ b/src/sharedInterfaces/telemetry.ts @@ -79,6 +79,8 @@ export enum TelemetryActions { ContinueEditing = "ContinueEditing", Close = "Close", SurveySubmit = "SurveySubmit", + profileLoaded = "profileLoaded", + profileSaved = "profileSaved", SaveResults = "SaveResults", CopyResults = "CopyResults", CopyResultsHeaders = "CopyResultsHeaders", From 147a406d12a284d9b97fc264b58bc34d49dbb4b5 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 9 Oct 2025 15:04:32 -0500 Subject: [PATCH 48/94] exclude options are default to empty, add upon selection --- src/publishProject/publishProjectWebViewController.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 92b1209d4e..17ad4aca35 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -84,6 +84,14 @@ export class PublishProjectWebViewController extends FormWebviewController< this._sqlProjectsService = sqlProjectsService; this._dacFxService = dacFxService; + // Clear default excludeObjectTypes for publish dialog, no default exclude options should exist + if ( + this.state.deploymentOptions && + this.state.deploymentOptions.excludeObjectTypes !== undefined + ) { + this.state.deploymentOptions.excludeObjectTypes.value = []; + } + // Register reducers after initialization this.registerRpcHandlers(); @@ -218,7 +226,7 @@ export class PublishProjectWebViewController extends FormWebviewController< if (this._dacFxService) { try { const databaseName = state.formState.databaseName || projectName; - // TODO: Build connection string from state.formState.serverName and connection details + // TODO: Build connection string from connection details when server/database selection is implemented const connectionString = ""; const sqlCmdVariables = new Map( Object.entries(state.formState.sqlCmdVariables || {}), From ff917e99ae48ee5075023c1ece9fa3e0cb2e221d Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 9 Oct 2025 17:52:17 -0500 Subject: [PATCH 49/94] reading publish.xml on selct profile --- src/publishProject/projectUtils.ts | 138 +++++++++++++----- .../publishProjectWebViewController.ts | 46 ++++-- 2 files changed, 137 insertions(+), 47 deletions(-) diff --git a/src/publishProject/projectUtils.ts b/src/publishProject/projectUtils.ts index a5e3410bb1..95bfe78dd1 100644 --- a/src/publishProject/projectUtils.ts +++ b/src/publishProject/projectUtils.ts @@ -8,6 +8,7 @@ import * as path from "path"; import * as constants from "../constants/constants"; import { SqlProjectsService } from "../services/sqlProjectsService"; import type { ProjectProperties } from "../sharedInterfaces/publishDialog"; +import { promises as fs } from "fs"; /** * Target platforms for a sql project @@ -169,46 +170,113 @@ export function isValidSqlAdminPassword(password: string, userName = "sa"): bool } /** - * Parses HTML string with anchor tags into a structured format suitable for React rendering. - * Converts text tags into React-compatible elements. - * - * @param html - HTML string potentially containing anchor tags - * @returns Object with parts array (text/link segments) or undefined if no HTML - * - * @example - * const result = parseHtmlLabel('I accept the Terms'); - * // Returns: { parts: ['I accept the ', { href: 'https://example.com', text: 'Terms' }] } + * Read SQLCMD variables from publish profile text + * @param profileText Publish profile XML text + * @returns Object with SQLCMD variable names as keys and values */ -export function parseHtmlLabel( - html: string | undefined, -): { parts: Array } | undefined { - if (!html) return undefined; - - // Simple parser for anchor tags - matches text - const anchorRegex = /]*?)href="([^"]*)"([^>]*?)>(.*?)<\/a>/gi; - - const parts: Array = []; - let lastIndex = 0; - let match: RegExpExecArray | undefined; - - while ((match = anchorRegex.exec(html) ?? undefined)) { - // Add text before the link - if (match.index > lastIndex) { - parts.push(html.substring(lastIndex, match.index)); +export function readSqlCmdVariables(profileText: string): { [key: string]: string } { + const sqlCmdVariables: { [key: string]: string } = {}; + const sqlCmdVarRegex = + /\s*(.*?)<\/Value>\s*<\/SqlCmdVariable>/gs; + let match; + while ((match = sqlCmdVarRegex.exec(profileText)) !== undefined) { + if (!match) { + break; } + const varName = match[1]; + const varValue = match[2]; + sqlCmdVariables[varName] = varValue; + } + return sqlCmdVariables; +} + +/** + * Read connection string from publish profile text + * @param profileText Publish profile XML text + * @returns Connection string and server name + */ +export function readConnectionString(profileText: string): { + connectionString: string; + server: string; +} { + // Parse TargetConnectionString + const connStrMatch = profileText.match( + /(.*?)<\/TargetConnectionString>/s, + ); + const connectionString = connStrMatch ? connStrMatch[1].trim() : ""; - // Add the link metadata - const href = match[2]; - const linkText = match[4]; - parts.push({ href, text: linkText }); + // Extract server name from connection string + const server = extractServerFromConnectionString(connectionString); - lastIndex = anchorRegex.lastIndex; - } + return { connectionString, server }; +} - // Add remaining text after last link - if (lastIndex < html.length) { - parts.push(html.substring(lastIndex)); +/** + * Extracts the server name from a SQL Server connection string + */ +export function extractServerFromConnectionString(connectionString: string): string { + if (!connectionString) { + return ""; } - return parts.length > 0 ? { parts } : undefined; + // Match "Data Source=serverName" or "Server=serverName" (case-insensitive) + const match = connectionString.match(/(?:Data Source|Server)=([^;]+)/i); + return match ? match[1].trim() : ""; +} + +/** + * Parses a publish profile XML file to extract database name, connection string, SQLCMD variables, and deployment options + * Uses regex parsing for XML fields and DacFx service getOptionsFromProfile() for deployment options + * @param profilePath Path to the publish profile XML file + * @param dacFxService DacFx service instance for getting deployment options from profile + */ +export async function parsePublishProfileXml( + profilePath: string, + dacFxService?: mssql.IDacFxService, +): Promise<{ + databaseName: string; + serverName: string; + connectionString: string; + sqlCmdVariables: { [key: string]: string }; + deploymentOptions?: mssql.DeploymentOptions; +}> { + try { + const profileText = await fs.readFile(profilePath, "utf-8"); + + // Read target database name + // if there is more than one TargetDatabaseName nodes, SSDT uses the name in the last one so we'll do the same here + let databaseName = ""; + const dbNameMatches = profileText.matchAll( + /(.*?)<\/TargetDatabaseName>/g, + ); + const dbNameArray = Array.from(dbNameMatches); + if (dbNameArray.length > 0) { + databaseName = dbNameArray[dbNameArray.length - 1][1]; + } + + // Read connection string using readConnectionString function + const connectionInfo = readConnectionString(profileText); + const connectionString = connectionInfo.connectionString; + const serverName = connectionInfo.server; + + // Get all SQLCMD variables using readSqlCmdVariables function + const sqlCmdVariables = readSqlCmdVariables(profileText); + + // Get deployment options from DacFx service using getOptionsFromProfile + let deploymentOptions: mssql.DeploymentOptions | undefined = undefined; + if (dacFxService) { + try { + const optionsResult = await dacFxService.getOptionsFromProfile(profilePath); + if (optionsResult.success && optionsResult.deploymentOptions) { + deploymentOptions = optionsResult.deploymentOptions; + } + } catch (error) { + console.warn("Failed to load deployment options from profile:", error); + } + } + + return { databaseName, serverName, connectionString, sqlCmdVariables, deploymentOptions }; + } catch (error) { + throw new Error(`Failed to parse publish profile: ${error}`); + } } diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 17ad4aca35..be56c6ab08 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -21,7 +21,7 @@ import { TelemetryActions, TelemetryViews } from "../sharedInterfaces/telemetry" import { sendActionEvent } from "../telemetry/telemetry"; import { generatePublishFormComponents } from "./formComponentHelpers"; import { loadDockerTags } from "./dockerUtils"; -import { readProjectProperties } from "./projectUtils"; +import { readProjectProperties, parsePublishProfileXml } from "./projectUtils"; import { SqlProjectsService } from "../services/sqlProjectsService"; import { Deferred } from "../protocol"; @@ -177,17 +177,39 @@ export class PublishProjectWebViewController extends FormWebviewController< if (fileUris && fileUris.length > 0) { const selectedPath = fileUris[0].fsPath; - // Send telemetry for profile loaded - sendActionEvent(TelemetryViews.SqlProjects, TelemetryActions.profileLoaded); - - // Update the publishProfilePath in form state - return { - ...state, - formState: { - ...state.formState, - publishProfilePath: selectedPath, - }, - }; + try { + // Parse the profile XML to extract all values, including deployment options from DacFx service + const parsedProfile = await parsePublishProfileXml( + selectedPath, + this._dacFxService, + ); + + // Send telemetry for profile loaded + sendActionEvent(TelemetryViews.SqlProjects, TelemetryActions.profileLoaded); + + void vscode.window.showInformationMessage( + `Publish profile loaded: ${selectedPath}`, + ); + + // Update state with all parsed values - UI components will consume when available + return { + ...state, + formState: { + ...state.formState, + publishProfilePath: selectedPath, + databaseName: + parsedProfile.databaseName || state.formState.databaseName, + serverName: parsedProfile.serverName || state.formState.serverName, + sqlCmdVariables: parsedProfile.sqlCmdVariables, + // TODO: connectionString stored in parsed profile, will be used when connection UI is ready + }, + deploymentOptions: + parsedProfile.deploymentOptions || state.deploymentOptions, + }; + } catch (error) { + void vscode.window.showErrorMessage(`Failed to load publish profile: ${error}`); + return state; + } } return state; From 594fedaa66903da699c5883b3460928584c89e86 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 9 Oct 2025 17:57:03 -0500 Subject: [PATCH 50/94] removing unsed method --- src/publishProject/projectUtils.ts | 45 ------------------------------ 1 file changed, 45 deletions(-) diff --git a/src/publishProject/projectUtils.ts b/src/publishProject/projectUtils.ts index 5736620645..9116aa6cb8 100644 --- a/src/publishProject/projectUtils.ts +++ b/src/publishProject/projectUtils.ts @@ -170,48 +170,3 @@ export function isValidSqlAdminPassword(password: string, userName = "sa"): bool const hasSymbol = /\W/.test(password) ? 1 : 0; return hasUpper + hasLower + hasDigit + hasSymbol >= 3; } - -/** - * Parses HTML string with anchor tags into a structured format suitable for React rendering. - * Converts text tags into React-compatible elements. - * - * @param html - HTML string potentially containing anchor tags - * @returns Object with parts array (text/link segments) or undefined if no HTML - * - * @example - * const result = parseHtmlLabel('I accept the Terms'); - * // Returns: { parts: ['I accept the ', { href: 'https://example.com', text: 'Terms' }] } - */ -export function parseHtmlLabel( - html: string | undefined, -): { parts: Array } | undefined { - if (!html) return undefined; - - // Simple parser for anchor tags - matches text - const anchorRegex = /]*?)href="([^"]*)"([^>]*?)>(.*?)<\/a>/gi; - - const parts: Array = []; - let lastIndex = 0; - let match: RegExpExecArray | undefined; - - while ((match = anchorRegex.exec(html) ?? undefined)) { - // Add text before the link - if (match.index > lastIndex) { - parts.push(html.substring(lastIndex, match.index)); - } - - // Add the link metadata - const href = match[2]; - const linkText = match[4]; - parts.push({ href, text: linkText }); - - lastIndex = anchorRegex.lastIndex; - } - - // Add remaining text after last link - if (lastIndex < html.length) { - parts.push(html.substring(lastIndex)); - } - - return parts.length > 0 ? { parts } : undefined; -} From d1abe67e8aed9b4fc96c5286b66bf9ebfa2450a2 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Fri, 10 Oct 2025 00:31:52 -0500 Subject: [PATCH 51/94] Final changes for the profile section --- localization/l10n/bundle.l10n.json | 5 +- localization/xliff/vscode-mssql.xlf | 6 +- src/constants/locConstants.ts | 7 +- src/controllers/mainController.ts | 2 +- .../publishProjectWebViewController.ts | 93 ++++---- src/sharedInterfaces/telemetry.ts | 4 +- .../publishProjectWebViewController.test.ts | 217 +++++++++--------- 7 files changed, 170 insertions(+), 164 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 0aa47757f9..d1282ea2b8 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1431,7 +1431,7 @@ "Publish Profile": "Publish Profile", "Select or enter a publish profile": "Select or enter a publish profile", "Save As": "Save As", - "Publish Profile Files": "Publish Profile Files", + "Publish Settings File": "Publish Settings File", "Database name is required": "Database name is required", "SQLCMD Variables": "SQLCMD Variables", "Publish Target": "Publish Target", @@ -1453,6 +1453,9 @@ "Port must be a number between 1 and 65535": "Port must be a number between 1 and 65535", "Invalid SQL Server password for {0}. Password must be 8–128 characters long and meet the complexity requirements. For more information see https://docs.microsoft.com/sql/relational-databases/security/password-policy": "Invalid SQL Server password for {0}. Password must be 8–128 characters long and meet the complexity requirements. For more information see https://docs.microsoft.com/sql/relational-databases/security/password-policy", "{0} password doesn't match the confirmation password": "{0} password doesn't match the confirmation password", + "Failed to load publish profile": "Failed to load publish profile", + "Publish profile saved to: {0}": "Publish profile saved to: {0}", + "Failed to save publish profile": "Failed to save publish profile", "Schema Compare": "Schema Compare", "Options have changed. Recompare to see the comparison?": "Options have changed. Recompare to see the comparison?", "Failed to generate script: '{0}'/{0} is the error message returned from the generate script operation": { diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 8709a28a60..f927bc69e1 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -2485,12 +2485,12 @@ Publish Profile - - Publish Profile Files - Publish Project + + Publish Settings File + Publish Target diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index d116bb79b2..f451fafdcc 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1285,7 +1285,7 @@ export class PublishProject { public static PublishProfilePlaceholder = l10n.t("Select or enter a publish profile"); public static SelectPublishProfile = l10n.t("Select Profile"); public static SaveAs = l10n.t("Save As"); - public static PublishProfileFiles = l10n.t("Publish Profile Files"); + public static PublishSettingsFile = l10n.t("Publish Settings File"); public static ServerLabel = l10n.t("Server"); public static DatabaseLabel = l10n.t("Database"); public static DatabaseRequiredMessage = l10n.t("Database name is required"); @@ -1322,6 +1322,11 @@ export class PublishProject { public static PasswordNotMatchMessage = (name: string) => { return l10n.t("{0} password doesn't match the confirmation password", name); }; + public static PublishProfileLoadFailed = l10n.t("Failed to load publish profile"); + public static PublishProfileSavedSuccessfully = (path: string) => { + return l10n.t("Publish profile saved to: {0}", path); + }; + public static PublishProfileSaveFailed = l10n.t("Failed to save publish profile"); } export class SchemaCompare { diff --git a/src/controllers/mainController.ts b/src/controllers/mainController.ts index 37b869f549..24fb3fb530 100644 --- a/src/controllers/mainController.ts +++ b/src/controllers/mainController.ts @@ -2610,9 +2610,9 @@ export default class MainController implements vscode.Disposable { this._context, this._vscodeWrapper, projectFilePath, - deploymentOptions.defaultDeploymentOptions, this.sqlProjectsService, this.dacFxService, + deploymentOptions.defaultDeploymentOptions, ); publishProjectWebView.revealToForeground(); diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index be56c6ab08..6573ed8537 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -39,9 +39,9 @@ export class PublishProjectWebViewController extends FormWebviewController< context: vscode.ExtensionContext, _vscodeWrapper: VscodeWrapper, projectFilePath: string, + sqlProjectsService: SqlProjectsService, + dacFxService: mssql.IDacFxService, deploymentOptions?: mssql.DeploymentOptions, - sqlProjectsService?: SqlProjectsService, - dacFxService?: mssql.IDacFxService, ) { super( context, @@ -170,7 +170,7 @@ export class PublishProjectWebViewController extends FormWebviewController< defaultUri: projectFolderPath ? vscode.Uri.file(projectFolderPath) : undefined, openLabel: Loc.SelectPublishProfile, filters: { - [Loc.PublishProfileFiles]: [constants.PublishProfileExtension], + [Loc.PublishSettingsFile]: [constants.PublishProfileExtension], }, }); @@ -185,10 +185,9 @@ export class PublishProjectWebViewController extends FormWebviewController< ); // Send telemetry for profile loaded - sendActionEvent(TelemetryViews.SqlProjects, TelemetryActions.profileLoaded); - - void vscode.window.showInformationMessage( - `Publish profile loaded: ${selectedPath}`, + sendActionEvent( + TelemetryViews.SqlProjects, + TelemetryActions.PublishProfileLoaded, ); // Update state with all parsed values - UI components will consume when available @@ -207,8 +206,9 @@ export class PublishProjectWebViewController extends FormWebviewController< parsedProfile.deploymentOptions || state.deploymentOptions, }; } catch (error) { - void vscode.window.showErrorMessage(`Failed to load publish profile: ${error}`); - return state; + void vscode.window.showErrorMessage( + `${Loc.PublishProfileLoadFailed}: ${error}`, + ); } } @@ -236,7 +236,7 @@ export class PublishProjectWebViewController extends FormWebviewController< defaultUri: defaultPath, saveLabel: Loc.SaveAs, filters: { - [Loc.PublishProfileFiles]: [constants.PublishProfileExtension], + [Loc.PublishSettingsFile]: [constants.PublishProfileExtension], }, }); @@ -244,50 +244,39 @@ export class PublishProjectWebViewController extends FormWebviewController< return state; // User cancelled } - // Call DacFx service to save the profile - if (this._dacFxService) { - try { - const databaseName = state.formState.databaseName || projectName; - // TODO: Build connection string from connection details when server/database selection is implemented - const connectionString = ""; - const sqlCmdVariables = new Map( - Object.entries(state.formState.sqlCmdVariables || {}), - ); - - await this._dacFxService.savePublishProfile( - fileUri.fsPath, - databaseName, - connectionString, - sqlCmdVariables, - state.deploymentOptions, - ); - - // Send telemetry for profile saved - sendActionEvent(TelemetryViews.SqlProjects, TelemetryActions.profileSaved); - - void vscode.window.showInformationMessage( - `Publish profile saved to: ${fileUri.fsPath}`, - ); - } catch (error) { - void vscode.window.showErrorMessage( - `Failed to save publish profile: ${error}`, - ); - } - - return state; + // Save the profile using DacFx service + try { + const databaseName = state.formState.databaseName || projectName; + // TODO: Build connection string from connection details when server/database selection is implemented + const connectionString = ""; + const sqlCmdVariables = new Map( + Object.entries(state.formState.sqlCmdVariables || {}), + ); + + await this._dacFxService!.savePublishProfile( + fileUri.fsPath, + databaseName, + connectionString, + sqlCmdVariables, + state.deploymentOptions, + ); + + // Send telemetry for profile saved + sendActionEvent( + TelemetryViews.SqlProjects, + TelemetryActions.PublishProfileSaved, + ); + + void vscode.window.showInformationMessage( + Loc.PublishProfileSavedSuccessfully(fileUri.fsPath), + ); + } catch (error) { + void vscode.window.showErrorMessage( + `${Loc.PublishProfileSaveFailed}: ${error}`, + ); } - // If DacFx service is not available, just update the path - void vscode.window.showWarningMessage( - "DacFx service not available. Profile path updated but not saved.", - ); - return { - ...state, - formState: { - ...state.formState, - publishProfilePath: fileUri.fsPath, - }, - }; + return state; }, ); } diff --git a/src/sharedInterfaces/telemetry.ts b/src/sharedInterfaces/telemetry.ts index eae28daa19..cda85af332 100644 --- a/src/sharedInterfaces/telemetry.ts +++ b/src/sharedInterfaces/telemetry.ts @@ -79,8 +79,8 @@ export enum TelemetryActions { ContinueEditing = "ContinueEditing", Close = "Close", SurveySubmit = "SurveySubmit", - profileLoaded = "profileLoaded", - profileSaved = "profileSaved", + PublishProfileLoaded = "PublishProfileLoaded", + PublishProfileSaved = "PublishProfileSaved", SaveResults = "SaveResults", CopyResults = "CopyResults", CopyResultsHeaders = "CopyResultsHeaders", diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 43319e6b7e..9731e87d9e 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -20,6 +20,10 @@ suite("PublishProjectWebViewController Tests", () => { let sandbox: sinon.SinonSandbox; let contextStub: vscode.ExtensionContext; let vscodeWrapperStub: sinon.SinonStubbedInstance; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockSqlProjectsService: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockDacFxService: any; setup(() => { sandbox = sinon.createSandbox(); @@ -32,40 +36,48 @@ suite("PublishProjectWebViewController Tests", () => { contextStub = rawContext as vscode.ExtensionContext; vscodeWrapperStub = stubVscodeWrapper(sandbox); + mockSqlProjectsService = {}; + mockDacFxService = {}; }); teardown(() => { sandbox.restore(); }); - test("constructor initializes state and derives database name", () => { - const projectPath = "c:/work/MySampleProject.sqlproj"; - const controller = new PublishProjectWebViewController( + /** + * Helper factory to create PublishProjectWebViewController with default test setup. + * @param projectPath Optional project path (defaults to standard test path) + */ + function createTestController( + projectPath = "c:/work/TestProject.sqlproj", + ): PublishProjectWebViewController { + return new PublishProjectWebViewController( contextStub, vscodeWrapperStub, projectPath, + mockSqlProjectsService, + mockDacFxService, ); + } + + test("constructor initializes state and derives database name", async () => { + const controller = createTestController("c:/work/MySampleProject.sqlproj"); + + await controller.initialized.promise; // Verify initial state - expect(controller.state.projectFilePath).to.equal(projectPath); + expect(controller.state.projectFilePath).to.equal("c:/work/MySampleProject.sqlproj"); expect(controller.state.formState.databaseName).to.equal("MySampleProject"); - // Form components should be initialized synchronously + // Form components should be initialized after initialization completes const components = controller.state.formComponents; - // Basic fields expected from generatePublishFormComponents() - expect(components.publishProfilePath, "publishProfilePath component should exist").to.exist; expect(components.serverName, "serverName component should exist").to.exist; expect(components.databaseName, "databaseName component should exist").to.exist; expect(components.publishTarget, "publishTarget component should exist").to.exist; }); test("reducer handlers are registered on construction", async () => { - const projectPath = "c:/work/TestProject.sqlproj"; - const controller = new PublishProjectWebViewController( - contextStub, - vscodeWrapperStub, - projectPath, - ); + const controller = createTestController(); await controller.initialized.promise; @@ -90,12 +102,7 @@ suite("PublishProjectWebViewController Tests", () => { }); test("default publish target is EXISTING_SERVER", async () => { - const projectPath = "c:/work/TestProject.sqlproj"; - const controller = new PublishProjectWebViewController( - contextStub, - vscodeWrapperStub, - projectPath, - ); + const controller = createTestController(); await controller.initialized.promise; @@ -103,12 +110,7 @@ suite("PublishProjectWebViewController Tests", () => { }); test("getActiveFormComponents returns correct fields for EXISTING_SERVER target", async () => { - const projectPath = "c:/work/TestProject.sqlproj"; - const controller = new PublishProjectWebViewController( - contextStub, - vscodeWrapperStub, - projectPath, - ); + const controller = createTestController(); await controller.initialized.promise; @@ -128,13 +130,9 @@ suite("PublishProjectWebViewController Tests", () => { expect(activeComponents).to.not.include("containerAdminPassword"); }); + //#region Publish Target Section Tests test("getActiveFormComponents returns correct fields for LOCAL_CONTAINER target", async () => { - const projectPath = "c:/work/TestProject.sqlproj"; - const controller = new PublishProjectWebViewController( - contextStub, - vscodeWrapperStub, - projectPath, - ); + const controller = createTestController(); await controller.initialized.promise; @@ -157,12 +155,7 @@ suite("PublishProjectWebViewController Tests", () => { }); test("state tracks inProgress and lastPublishResult", async () => { - const projectPath = "c:/work/TestProject.sqlproj"; - const controller = new PublishProjectWebViewController( - contextStub, - vscodeWrapperStub, - projectPath, - ); + const controller = createTestController(); await controller.initialized.promise; @@ -176,12 +169,7 @@ suite("PublishProjectWebViewController Tests", () => { }); test("container target values are properly saved to formState", async () => { - const projectPath = "c:/work/ContainerProject.sqlproj"; - const controller = new PublishProjectWebViewController( - contextStub, - vscodeWrapperStub, - projectPath, - ); + const controller = createTestController("c:/work/ContainerProject.sqlproj"); await controller.initialized.promise; @@ -239,21 +227,14 @@ suite("PublishProjectWebViewController Tests", () => { }); test("Azure SQL project shows Azure-specific labels", async () => { - const mockSqlProjectsService = { - getProjectProperties: sandbox.stub().resolves({ - success: true, - projectGuid: "test-guid", - databaseSchemaProvider: - "Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider", - }), - }; + mockSqlProjectsService.getProjectProperties = sandbox.stub().resolves({ + success: true, + projectGuid: "test-guid", + databaseSchemaProvider: + "Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider", + }); - const controller = new PublishProjectWebViewController( - contextStub, - vscodeWrapperStub, - "c:/work/AzureProject.sqlproj", - mockSqlProjectsService as any, // eslint-disable-line @typescript-eslint/no-explicit-any - ); + const controller = createTestController("c:/work/AzureProject.sqlproj"); await controller.initialized.promise; @@ -277,21 +258,14 @@ suite("PublishProjectWebViewController Tests", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - const mockSqlProjectsService = { - getProjectProperties: sandbox.stub().resolves({ - success: true, - projectGuid: "test-guid", - databaseSchemaProvider: - "Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider", - }), - }; + mockSqlProjectsService.getProjectProperties = sandbox.stub().resolves({ + success: true, + projectGuid: "test-guid", + databaseSchemaProvider: + "Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider", + }); - const controller = new PublishProjectWebViewController( - contextStub, - vscodeWrapperStub, - "c:/work/AzureProject.sqlproj", - mockSqlProjectsService as any, // eslint-disable-line @typescript-eslint/no-explicit-any - ); + const controller = createTestController("c:/work/AzureProject.sqlproj"); await controller.initialized.promise; @@ -322,44 +296,73 @@ suite("PublishProjectWebViewController Tests", () => { expect(isValidSqlAdminPassword("Pass1"), "too short invalid").to.be.false; expect(isValidSqlAdminPassword("Password123!".repeat(20)), "too long invalid").to.be.false; }); + //#endregion - test("selectPublishProfile reducer is invoked and triggers file picker", async () => { - const projectPath = "c:/work/TestProject.sqlproj"; - const controller = new PublishProjectWebViewController( - contextStub, - vscodeWrapperStub, - projectPath, - ); - + //#region Publish Profile Section Tests + test("selectPublishProfile reducer parses real-world XML profile correctly", async () => { + const controller = createTestController(); await controller.initialized.promise; - // Stub showOpenDialog to simulate user selecting a profile - const selectedProfilePath = "c:/profiles/MyProfile.publish.xml"; - const showOpenDialogStub = sandbox - .stub(vscode.window, "showOpenDialog") - .resolves([vscode.Uri.file(selectedProfilePath)]); + // Real-world ADS-generated publish profile XML with all features + const adsProfileXml = ` + + + True + MyDatabase + MyDatabase.sql + Data Source=myserver.database.windows.net;Persist Security Info=False;User ID=admin;Pooling=False;MultipleActiveResultSets=False; + 1 + + + + Value1 + + + Value2 + + +`; + + const profilePath = "c:/profiles/TestProfile.publish.xml"; + + // Mock file system read + const fs = await import("fs"); + sandbox.stub(fs.promises, "readFile").resolves(adsProfileXml); + + // Mock file picker + sandbox.stub(vscode.window, "showOpenDialog").resolves([vscode.Uri.file(profilePath)]); + + // Mock DacFx service to return deployment options + mockDacFxService.getOptionsFromProfile = sandbox.stub().resolves({ + success: true, + deploymentOptions: { + excludeObjectTypes: { value: ["Users", "Logins"] }, + ignoreTableOptions: { value: true }, + }, + }); const reducerHandlers = controller["_reducerHandlers"] as Map; const selectPublishProfile = reducerHandlers.get("selectPublishProfile"); expect(selectPublishProfile, "selectPublishProfile reducer should be registered").to.exist; // Invoke the reducer - await selectPublishProfile(controller.state, {}); - - // Verify file picker was shown - expect(showOpenDialogStub.calledOnce, "showOpenDialog should be called once").to.be.true; + const newState = await selectPublishProfile(controller.state, {}); + + // Verify parsed values are in the returned state (normalize paths for cross-platform) + expect(newState.formState.publishProfilePath.replace(/\\/g, "/")).to.equal(profilePath); + expect(newState.formState.databaseName).to.equal("MyDatabase"); + expect(newState.formState.serverName).to.equal("myserver.database.windows.net"); + expect(newState.formState.sqlCmdVariables).to.deep.equal({ + Var1: "Value1", + Var2: "Value2", + }); - // Verify the profile path was updated in formState - expect(controller.state.formState.publishProfilePath).to.equal(selectedProfilePath); + // Verify deployment options were loaded from DacFx + expect(mockDacFxService.getOptionsFromProfile.calledOnce).to.be.true; }); test("savePublishProfile reducer is invoked and triggers save file dialog", async () => { - const projectPath = "c:/work/TestProject.sqlproj"; - const controller = new PublishProjectWebViewController( - contextStub, - vscodeWrapperStub, - projectPath, - ); + const controller = createTestController(); await controller.initialized.promise; @@ -369,21 +372,27 @@ suite("PublishProjectWebViewController Tests", () => { // Stub showSaveDialog to simulate user choosing a save location const savedProfilePath = "c:/profiles/NewProfile.publish.xml"; - const showSaveDialogStub = sandbox - .stub(vscode.window, "showSaveDialog") - .resolves(vscode.Uri.file(savedProfilePath)); + sandbox.stub(vscode.window, "showSaveDialog").resolves(vscode.Uri.file(savedProfilePath)); + + // Mock DacFx service + mockDacFxService.savePublishProfile = sandbox.stub().resolves({ success: true }); const reducerHandlers = controller["_reducerHandlers"] as Map; const savePublishProfile = reducerHandlers.get("savePublishProfile"); expect(savePublishProfile, "savePublishProfile reducer should be registered").to.exist; // Invoke the reducer with an optional default filename - await savePublishProfile(controller.state, { event: "TestProject.publish.xml" }); + const newState = await savePublishProfile(controller.state, { + event: "TestProject.publish.xml", + }); - // Verify save dialog was shown - expect(showSaveDialogStub.calledOnce, "showSaveDialog should be called once").to.be.true; + // Verify DacFx save was called + expect(mockDacFxService.savePublishProfile.calledOnce).to.be.true; - // Verify the saved profile path was updated in formState - expect(controller.state.formState.publishProfilePath).to.equal(savedProfilePath); + // Verify the state is returned unchanged (savePublishProfile does NOT update path in state) + expect(newState.formState.publishProfilePath).to.equal( + controller.state.formState.publishProfilePath, + ); }); + //#endregion }); From de953cae0dff42704fa5259262399ea884928a51 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Fri, 10 Oct 2025 00:45:46 -0500 Subject: [PATCH 52/94] LOC missing changes addding back --- localization/xliff/vscode-mssql.xlf | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index f927bc69e1..b78bf029c4 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -1413,6 +1413,9 @@ {0} is the tenant id {1} is the account name + + Failed to load publish profile + Failed to open scmp file: '{0}' {0} is the error message returned from the open scmp operation @@ -1425,6 +1428,9 @@ {0} is the connection id {1} is the uri + + Failed to save publish profile + Failed to save results. @@ -2494,6 +2500,9 @@ Publish Target + + Publish profile saved to: {0} + Publishing Changes From 6b7b5824e8ff54edd2a0344d8310721aa276ca5b Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Fri, 10 Oct 2025 17:08:13 -0500 Subject: [PATCH 53/94] adding icon and disabling server input --- .../components/ConnectionSection.tsx | 38 +++- .../components/FormFieldComponents.tsx | 10 +- .../components/PublishProfileSection.tsx | 38 ++-- .../components/PublishTargetSection.tsx | 197 +++++++++--------- 4 files changed, 163 insertions(+), 120 deletions(-) diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx index cbe7706047..4e9100b274 100644 --- a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -4,12 +4,27 @@ *--------------------------------------------------------------------------------------------*/ import { useContext, useState, useEffect } from "react"; +import { Button, makeStyles } from "@fluentui/react-components"; +import { PlugDisconnectedRegular } from "@fluentui/react-icons"; import { PublishProjectContext } from "../publishProjectStateProvider"; import { usePublishDialogSelector } from "../publishDialogSelector"; import { renderInput } from "./FormFieldComponents"; +import { useFormStyles } from "../../../common/forms/form.component"; + +const useStyles = makeStyles({ + root: { + display: "flex", + flexDirection: "column", + gap: "8px", + maxWidth: "640px", + width: "100%", + }, +}); export const ConnectionSection: React.FC = () => { const publishCtx = useContext(PublishProjectContext); + const formStyles = useFormStyles(); + const classes = useStyles(); const serverComponent = usePublishDialogSelector((s) => s.formComponents.serverName); const databaseComponent = usePublishDialogSelector((s) => s.formComponents.databaseName); const serverValue = usePublishDialogSelector((s) => s.formState.serverName); @@ -26,9 +41,24 @@ export const ConnectionSection: React.FC = () => { } return ( - <> - {renderInput(serverComponent, localServer, setLocalServer)} - {renderInput(databaseComponent, localDatabase, setLocalDatabase)} - +
+
+ {renderInput(serverComponent, localServer, setLocalServer, { + readOnly: true, + contentAfter: ( +
+
); }; diff --git a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx index d4c3e88fef..bdde5acc77 100644 --- a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx +++ b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx @@ -25,6 +25,7 @@ export const renderInput = ( showPassword?: boolean; onTogglePassword?: () => void; readOnly?: boolean; + contentAfter?: React.ReactElement; }, ) => { if (!component || component.hidden) return undefined; @@ -43,7 +44,7 @@ export const renderInput = ( validationState={getValidationState(component.validation)} orientation="horizontal"> onChange(data.value)} onBlur={() => options?.onBlur?.(value)} contentAfter={ - isPasswordField && options?.onTogglePassword ? ( + options?.contentAfter ? ( + options.contentAfter + ) : isPasswordField && options?.onTogglePassword ? ( - +
+
+
+ {renderInput(component, localValue, setLocalValue, { readOnly: true })} +
+
+ + +
); diff --git a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx index b6023cb486..1064e03014 100644 --- a/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx +++ b/src/reactviews/pages/PublishProject/components/PublishTargetSection.tsx @@ -14,6 +14,7 @@ import { } from "../../../../sharedInterfaces/publishDialog"; import { renderInput, renderDropdown, renderCheckbox } from "./FormFieldComponents"; import { parseHtmlLabel } from "../../../common/utils"; +import { useFormStyles } from "../../../common/forms/form.component"; const useStyles = makeStyles({ root: { @@ -34,6 +35,7 @@ const useStyles = makeStyles({ export const PublishTargetSection: React.FC = () => { const classes = useStyles(); + const formStyles = useFormStyles(); const publishCtx = useContext(PublishProjectContext); // Select form components and values - components needed for rendering, values for logic @@ -148,124 +150,127 @@ export const PublishTargetSection: React.FC = () => { } return ( -
- {/* Publish Target Dropdown */} - {renderDropdown(targetComponent, targetValue, (val) => { - publishCtx.formAction({ - propertyName: targetComponent.propertyName, - isAction: false, - value: val, - }); - })} +
+
+ {/* Publish Target Dropdown */} + {renderDropdown(targetComponent, targetValue, (val) => { + publishCtx.formAction({ + propertyName: targetComponent.propertyName, + isAction: false, + value: val, + }); + })} - {/* Container Fields - Shown only when local container is selected */} - {isContainer && ( -
- {/* Container Port */} - {renderInput( - portComponent, - portValue?.toString() || "", - (val) => { - portComponent && - publishCtx.formAction({ - propertyName: portComponent.propertyName, - isAction: false, - value: val, - updateValidation: false, - }); - }, - { - onBlur: (val) => { + {/* Container Fields - Shown only when local container is selected */} + {isContainer && ( +
+ {/* Container Port */} + {renderInput( + portComponent, + portValue?.toString() || "", + (val) => { portComponent && publishCtx.formAction({ propertyName: portComponent.propertyName, isAction: false, value: val, - updateValidation: true, + updateValidation: false, }); }, - }, - )} - - {/* Admin Password */} - {renderInput(passwordComponent, localAdminPassword, setLocalAdminPassword, { - showPassword: showAdminPassword, - onTogglePassword: () => setShowAdminPassword(!showAdminPassword), - onBlur: (val) => { - passwordComponent && - publishCtx.formAction({ - propertyName: passwordComponent.propertyName, - isAction: false, - value: val, - updateValidation: true, - }); - }, - })} + { + onBlur: (val) => { + portComponent && + publishCtx.formAction({ + propertyName: portComponent.propertyName, + isAction: false, + value: val, + updateValidation: true, + }); + }, + }, + )} - {/* Confirm Password */} - {renderInput( - confirmPasswordComponent, - localConfirmPassword, - setLocalConfirmPassword, - { - showPassword: showConfirmPassword, - onTogglePassword: () => setShowConfirmPassword(!showConfirmPassword), + {/* Admin Password */} + {renderInput(passwordComponent, localAdminPassword, setLocalAdminPassword, { + showPassword: showAdminPassword, + onTogglePassword: () => setShowAdminPassword(!showAdminPassword), onBlur: (val) => { - confirmPasswordComponent && + passwordComponent && publishCtx.formAction({ - propertyName: confirmPasswordComponent.propertyName, + propertyName: passwordComponent.propertyName, isAction: false, value: val, updateValidation: true, }); }, - }, - )} + })} - {/* Container Image Tag */} - {renderDropdown(imageTagComponent, imageTagValue?.toString(), (val) => { - imageTagComponent && - publishCtx.formAction({ - propertyName: imageTagComponent.propertyName, - isAction: false, - value: val, - updateValidation: true, - }); - })} + {/* Confirm Password */} + {renderInput( + confirmPasswordComponent, + localConfirmPassword, + setLocalConfirmPassword, + { + showPassword: showConfirmPassword, + onTogglePassword: () => + setShowConfirmPassword(!showConfirmPassword), + onBlur: (val) => { + confirmPasswordComponent && + publishCtx.formAction({ + propertyName: confirmPasswordComponent.propertyName, + isAction: false, + value: val, + updateValidation: true, + }); + }, + }, + )} - {/* Accept License Checkbox */} - {renderCheckbox( - licenseComponent, - Boolean(licenseValue), - (checked) => { - licenseComponent && + {/* Container Image Tag */} + {renderDropdown(imageTagComponent, imageTagValue?.toString(), (val) => { + imageTagComponent && publishCtx.formAction({ - propertyName: licenseComponent.propertyName, + propertyName: imageTagComponent.propertyName, isAction: false, - value: checked, + value: val, updateValidation: true, }); - }, - licenseComponent?.label ? ( - <> - {parseHtmlLabel(licenseComponent.label)?.parts.map((part, i) => - typeof part === "string" ? ( - part - ) : ( - - {part.text} - - ), - )} - - ) : undefined, - )} -
- )} + })} + + {/* Accept License Checkbox */} + {renderCheckbox( + licenseComponent, + Boolean(licenseValue), + (checked) => { + licenseComponent && + publishCtx.formAction({ + propertyName: licenseComponent.propertyName, + isAction: false, + value: checked, + updateValidation: true, + }); + }, + licenseComponent?.label ? ( + <> + {parseHtmlLabel(licenseComponent.label)?.parts.map((part, i) => + typeof part === "string" ? ( + part + ) : ( + + {part.text} + + ), + )} + + ) : undefined, + )} +
+ )} +
); }; From b97075957c923e4d561dce26290b4f1dbfaf5df8 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Fri, 10 Oct 2025 23:32:06 -0500 Subject: [PATCH 54/94] connection work --- src/constants/locConstants.ts | 1 + src/controllers/mainController.ts | 1 + src/publishProject/formComponentHelpers.ts | 14 +- .../publishProjectWebViewController.ts | 121 +++++++++++++++++- .../components/ConnectionSection.tsx | 6 +- .../components/FormFieldComponents.tsx | 59 ++++++++- .../publishProjectStateProvider.tsx | 1 + src/sharedInterfaces/publishDialog.ts | 6 + .../publishProjectWebViewController.test.ts | 8 ++ 9 files changed, 207 insertions(+), 10 deletions(-) diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index f451fafdcc..bdb79f5402 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1302,6 +1302,7 @@ export class PublishProject { public static SqlServerAdminPassword = l10n.t("SQL Server admin password"); public static SqlServerAdminPasswordConfirm = l10n.t("Confirm SQL Server admin password"); public static SqlServerImageTag = l10n.t("Image tag"); + public static ServerConnectionPlaceholder = l10n.t("Select Connection"); public static UserLicenseAgreement = (licenseUrl: string) => l10n.t({ message: diff --git a/src/controllers/mainController.ts b/src/controllers/mainController.ts index 24fb3fb530..749d6bd39d 100644 --- a/src/controllers/mainController.ts +++ b/src/controllers/mainController.ts @@ -2609,6 +2609,7 @@ export default class MainController implements vscode.Disposable { const publishProjectWebView = new PublishProjectWebViewController( this._context, this._vscodeWrapper, + this.connectionManager, projectFilePath, this.sqlProjectsService, this.dacFxService, diff --git a/src/publishProject/formComponentHelpers.ts b/src/publishProject/formComponentHelpers.ts index 5931068a9d..54eb3a1c8c 100644 --- a/src/publishProject/formComponentHelpers.ts +++ b/src/publishProject/formComponentHelpers.ts @@ -64,12 +64,14 @@ function generatePublishTargetOptions(projectTargetVersion?: string): FormItemOp } /** - * Generate publish form components. Kept async for future extensibility - * (e.g. reading project metadata, fetching remote targets, etc.) - * @param projectTargetVersion - The target version of the project (e.g., "AzureV12" for Azure SQL) + * Generates the publish form components for the publish dialog. + * @param projectTargetVersion - The target version of the project. + * @param initialDatabaseName - The initial database name to populate in the database dropdown. + * @returns The generated form components. */ export function generatePublishFormComponents( projectTargetVersion?: string, + initialDatabaseName?: string, ): Record { const components: Record = { publishProfilePath: { @@ -83,12 +85,16 @@ export function generatePublishFormComponents( label: Loc.ServerLabel, required: true, type: FormItemType.Input, + placeholder: Loc.ServerConnectionPlaceholder, }, databaseName: { propertyName: constants.PublishFormFields.DatabaseName, label: Loc.DatabaseLabel, required: true, - type: FormItemType.Input, + type: FormItemType.Dropdown, + options: initialDatabaseName + ? [{ displayName: initialDatabaseName, value: initialDatabaseName }] + : [], validate: (_state: PublishDialogState, value: string) => { const isValid = (value ?? "").trim().length > 0; return { isValid, validationMessage: isValid ? "" : Loc.DatabaseRequiredMessage }; diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 6573ed8537..ad3b661e59 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -9,6 +9,8 @@ import * as mssql from "vscode-mssql"; import * as constants from "../constants/constants"; import { FormWebviewController } from "../forms/formWebviewController"; import VscodeWrapper from "../controllers/vscodeWrapper"; +import ConnectionManager from "../controllers/connectionManager"; +import { IConnectionProfile } from "../models/interfaces"; import { PublishProject as Loc } from "../constants/locConstants"; import { PublishDialogReducers, @@ -34,10 +36,12 @@ export class PublishProjectWebViewController extends FormWebviewController< public readonly initialized: Deferred = new Deferred(); private readonly _sqlProjectsService?: SqlProjectsService; private readonly _dacFxService?: mssql.IDacFxService; + private readonly _connectionManager: ConnectionManager; constructor( context: vscode.ExtensionContext, _vscodeWrapper: VscodeWrapper, + connectionManager: ConnectionManager, projectFilePath: string, sqlProjectsService: SqlProjectsService, dacFxService: mssql.IDacFxService, @@ -61,6 +65,8 @@ export class PublishProjectWebViewController extends FormWebviewController< inProgress: false, lastPublishResult: undefined, deploymentOptions: deploymentOptions, + waitingForNewConnection: false, + activeServers: {}, } as PublishDialogState, { title: Loc.Title, @@ -80,9 +86,10 @@ export class PublishProjectWebViewController extends FormWebviewController< }, ); - // Store the SQL Projects Service + // Store the SQL Projects Service and Connection Manager this._sqlProjectsService = sqlProjectsService; this._dacFxService = dacFxService; + this._connectionManager = connectionManager; // Clear default excludeObjectTypes for publish dialog, no default exclude options should exist if ( @@ -95,6 +102,34 @@ export class PublishProjectWebViewController extends FormWebviewController< // Register reducers after initialization this.registerRpcHandlers(); + // Listen for new connections (similar to schema compare) + this.registerDisposable( + this._connectionManager.onConnectionsChanged(async () => { + // Check if we're waiting for a new connection + if (this.state.waitingForNewConnection) { + const activeServers = this.getActiveServersList(); + const newConnections = this.findNewConnections( + this.state.activeServers, + activeServers, + ); + + if (newConnections.length > 0) { + // Update active servers first + this.state.activeServers = activeServers; + + // Auto-select the first new connection + const newConnectionUri = newConnections[0]; + await this.autoSelectNewConnection(newConnectionUri); + } + } else { + // Update active servers even if not waiting + this.state.activeServers = this.getActiveServersList(); + } + + this.updateState(); + }), + ); + // Initialize async to allow for future extensibility and proper error handling void this.initializeDialog(projectFilePath) .then(() => { @@ -130,7 +165,10 @@ export class PublishProjectWebViewController extends FormWebviewController< } // Load publish form components - this.state.formComponents = generatePublishFormComponents(projectTargetVersion); + this.state.formComponents = generatePublishFormComponents( + projectTargetVersion, + this.state.formState.databaseName, + ); // Update state to notify UI of the project properties and form components this.updateState(); @@ -149,6 +187,17 @@ export class PublishProjectWebViewController extends FormWebviewController< /** Registers all reducers in pure (immutable) style */ private registerRpcHandlers(): void { + this.registerReducer("openConnectionDialog", async (state: PublishDialogState) => { + // Set waiting state to detect new connections + state.waitingForNewConnection = true; + this.updateState(state); + + // Execute the command to open the connection dialog (same as "+" button in servers panel) + void vscode.commands.executeCommand(constants.cmdAddObjectExplorer); + + return state; + }); + this.registerReducer("publishNow", async (state: PublishDialogState) => { // TODO: implement actual publish logic (currently just clears inProgress) return { ...state, inProgress: false }; @@ -281,6 +330,74 @@ export class PublishProjectWebViewController extends FormWebviewController< ); } + /** Get the list of active server connections */ + private getActiveServersList(): { + [connectionUri: string]: { profileName: string; server: string }; + } { + const activeServers: { [connectionUri: string]: { profileName: string; server: string } } = + {}; + const activeConnections = this._connectionManager.activeConnections; + Object.keys(activeConnections).forEach((connectionUri) => { + const credentials = activeConnections[connectionUri].credentials as IConnectionProfile; + activeServers[connectionUri] = { + profileName: credentials.profileName ?? "", + server: credentials.server, + }; + }); + return activeServers; + } + + /** Find new connections that were added */ + private findNewConnections( + oldActiveServers: { [connectionUri: string]: { profileName: string; server: string } }, + newActiveServers: { [connectionUri: string]: { profileName: string; server: string } }, + ): string[] { + const newConnections: string[] = []; + for (const connectionUri in newActiveServers) { + if (!(connectionUri in oldActiveServers)) { + newConnections.push(connectionUri); + } + } + return newConnections; + } + + /** Auto-select a new connection and populate server/database fields */ + private async autoSelectNewConnection(connectionUri: string): Promise { + try { + // Get the list of databases for the new connection + const databases = await this._connectionManager.listDatabases(connectionUri); + + // Get the connection profile + const connection = this._connectionManager.activeConnections[connectionUri]; + const connectionProfile = connection?.credentials as IConnectionProfile; + + if (connectionProfile) { + // Update server name + this.state.formState.serverName = connectionProfile.server; + + // Update database dropdown options + const databaseComponent = + this.state.formComponents[constants.PublishFormFields.DatabaseName]; + if (databaseComponent) { + databaseComponent.options = databases.map((db) => ({ + displayName: db, + value: db, + })); + } + + // Optionally select the first database if available + if (databases.length > 0 && !this.state.formState.databaseName) { + this.state.formState.databaseName = databases[0]; + } + } + } catch { + // Silently fail - connection issues are handled elsewhere + } finally { + // Reset the waiting state + this.state.waitingForNewConnection = false; + } + } + protected getActiveFormComponents(state: PublishDialogState): (keyof IPublishForm)[] { const activeComponents: (keyof IPublishForm)[] = [ constants.PublishFormFields.PublishTarget, diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx index 4e9100b274..d804074b91 100644 --- a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -8,7 +8,7 @@ import { Button, makeStyles } from "@fluentui/react-components"; import { PlugDisconnectedRegular } from "@fluentui/react-icons"; import { PublishProjectContext } from "../publishProjectStateProvider"; import { usePublishDialogSelector } from "../publishDialogSelector"; -import { renderInput } from "./FormFieldComponents"; +import { renderInput, renderCombobox } from "./FormFieldComponents"; import { useFormStyles } from "../../../common/forms/form.component"; const useStyles = makeStyles({ @@ -52,12 +52,12 @@ export const ConnectionSection: React.FC = () => { icon={} appearance="transparent" onClick={() => { - // TODO: Open connection dialog + publishCtx.openConnectionDialog(); }} /> ), })} - {renderInput(databaseComponent, localDatabase, setLocalDatabase)} + {renderCombobox(databaseComponent, localDatabase, false, setLocalDatabase)}
); diff --git a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx index bdde5acc77..6b87d41eeb 100644 --- a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx +++ b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx @@ -3,7 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Field, Dropdown, Option, Input, Checkbox, Button } from "@fluentui/react-components"; +import { + Field, + Dropdown, + Option, + Input, + Checkbox, + Button, + Combobox, +} from "@fluentui/react-components"; import { EyeOffRegular, EyeRegular } from "@fluentui/react-icons"; import { FormItemType } from "../../../../sharedInterfaces/form"; import type { PublishDialogFormItemSpec } from "../../../../sharedInterfaces/publishDialog"; @@ -114,6 +122,55 @@ export const renderDropdown = ( ); }; +// Generic Combobox Field - can be used for editable dropdowns (allows custom text input) +export const renderCombobox = ( + component: PublishDialogFormItemSpec | undefined, + value: string | undefined, + freeform: boolean | undefined, + onChange: (value: string) => void, +) => { + if (!component || component.hidden) return undefined; + if (component.type !== FormItemType.Dropdown || !component.options) return undefined; + + return ( + + { + if (data.optionValue) { + onChange(data.optionValue); + } + }} + onChange={(event) => { + // Allow custom text input + onChange((event.target as HTMLInputElement).value); + }}> + {component.options.map( + (opt: { value: string; displayName: string; color?: string }, i: number) => ( + + ), + )} + + + ); +}; + // Generic Checkbox Field - can be used for any checkbox export const renderCheckbox = ( component: PublishDialogFormItemSpec | undefined, diff --git a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx index ff45f1329b..feba7bea0b 100644 --- a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx +++ b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx @@ -40,6 +40,7 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } selectPublishProfile: () => extensionRpc.action("selectPublishProfile"), savePublishProfile: (publishProfileName: string) => extensionRpc.action("savePublishProfile", { publishProfileName }), + openConnectionDialog: () => extensionRpc.action("openConnectionDialog"), extensionRpc, }), [extensionRpc], diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 60c69e6175..662f881c05 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -61,6 +61,9 @@ export interface PublishDialogState lastPublishResult?: { success: boolean; details?: string }; deploymentOptions?: mssql.DeploymentOptions; projectProperties?: ProjectProperties; + // Connection management state (similar to schema compare) + waitingForNewConnection?: boolean; + activeServers?: { [connectionUri: string]: { profileName: string; server: string } }; } /** @@ -88,6 +91,7 @@ export interface PublishDialogReducers extends FormReducers { openPublishAdvanced: {}; selectPublishProfile: {}; savePublishProfile: { publishProfileName: string }; + openConnectionDialog: {}; } /** @@ -111,4 +115,6 @@ export interface PublishProjectProvider { selectPublishProfile(): void; /** Persist current form state as a named publish profile */ savePublishProfile(publishProfileName: string): void; + /** Open connection dialog to select server and database */ + openConnectionDialog(): void; } diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 9731e87d9e..251d134899 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -24,6 +24,8 @@ suite("PublishProjectWebViewController Tests", () => { let mockSqlProjectsService: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockDacFxService: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockConnectionManager: any; setup(() => { sandbox = sinon.createSandbox(); @@ -38,6 +40,11 @@ suite("PublishProjectWebViewController Tests", () => { vscodeWrapperStub = stubVscodeWrapper(sandbox); mockSqlProjectsService = {}; mockDacFxService = {}; + mockConnectionManager = { + onConnectionsChanged: sandbox.stub().returns({ dispose: () => {} }), + activeConnections: {}, + listDatabases: sandbox.stub().resolves([]), + }; }); teardown(() => { @@ -54,6 +61,7 @@ suite("PublishProjectWebViewController Tests", () => { return new PublishProjectWebViewController( contextStub, vscodeWrapperStub, + mockConnectionManager, projectPath, mockSqlProjectsService, mockDacFxService, From e580db57b46b4b4ae92cfca31405ad12c6ab4054 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Fri, 10 Oct 2025 23:32:37 -0500 Subject: [PATCH 55/94] LOC --- localization/l10n/bundle.l10n.json | 1 + localization/xliff/vscode-mssql.xlf | 3 +++ 2 files changed, 4 insertions(+) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index d1282ea2b8..9932eb5dea 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1444,6 +1444,7 @@ "SQL Server admin password": "SQL Server admin password", "Confirm SQL Server admin password": "Confirm SQL Server admin password", "Image tag": "Image tag", + "Select Connection": "Select Connection", "I accept the Microsoft SQL Server License Agreement/{0} is the hyperlink URL to the Microsoft SQL Server License Agreement used in an HTML anchor tag": { "message": "I accept the Microsoft SQL Server License Agreement", "comment": [ diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index b78bf029c4..4e8beaaefe 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -2808,6 +2808,9 @@ Select Azure account with Key Vault access for column decryption + + Select Connection + Select Profile From 156fa694f36d7038f5e5cda2ac7a6bff671ff313 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Fri, 10 Oct 2025 23:42:48 -0500 Subject: [PATCH 56/94] adding conneciton string to the state and saving in profile --- .../publishProjectWebViewController.ts | 13 ++++++++++--- src/sharedInterfaces/publishDialog.ts | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index ad3b661e59..cbf69178c2 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -249,8 +249,8 @@ export class PublishProjectWebViewController extends FormWebviewController< parsedProfile.databaseName || state.formState.databaseName, serverName: parsedProfile.serverName || state.formState.serverName, sqlCmdVariables: parsedProfile.sqlCmdVariables, - // TODO: connectionString stored in parsed profile, will be used when connection UI is ready }, + connectionString: parsedProfile.connectionString || state.connectionString, deploymentOptions: parsedProfile.deploymentOptions || state.deploymentOptions, }; @@ -296,8 +296,7 @@ export class PublishProjectWebViewController extends FormWebviewController< // Save the profile using DacFx service try { const databaseName = state.formState.databaseName || projectName; - // TODO: Build connection string from connection details when server/database selection is implemented - const connectionString = ""; + const connectionString = state.connectionString || ""; const sqlCmdVariables = new Map( Object.entries(state.formState.sqlCmdVariables || {}), ); @@ -375,6 +374,14 @@ export class PublishProjectWebViewController extends FormWebviewController< // Update server name this.state.formState.serverName = connectionProfile.server; + // Get connection string (include password for publishing) + const connectionString = await this._connectionManager.getConnectionString( + connectionUri, + true, // includePassword + true, // includeApplicationName + ); + this.state.connectionString = connectionString; + // Update database dropdown options const databaseComponent = this.state.formComponents[constants.PublishFormFields.DatabaseName]; diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 662f881c05..64480ea44c 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -61,9 +61,9 @@ export interface PublishDialogState lastPublishResult?: { success: boolean; details?: string }; deploymentOptions?: mssql.DeploymentOptions; projectProperties?: ProjectProperties; - // Connection management state (similar to schema compare) waitingForNewConnection?: boolean; activeServers?: { [connectionUri: string]: { profileName: string; server: string } }; + connectionString?: string; } /** From d27953041ad307d9f22612664720b4d9a88ff416 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 14 Oct 2025 07:56:29 -0500 Subject: [PATCH 57/94] copilot review updates --- src/publishProject/publishProjectWebViewController.ts | 2 +- src/reactviews/pages/PublishProject/publishProject.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index a4de1629e0..70542f8943 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -47,7 +47,7 @@ export class PublishProjectWebViewController extends FormWebviewController< publishProfilePath: "", serverName: "", databaseName: path.basename(projectFilePath, path.extname(projectFilePath)), - publishTarget: "existingServer", + publishTarget: PublishTarget.ExistingServer, sqlCmdVariables: {}, }, formComponents: {}, diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index ac17cadff1..a2a4f904de 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -39,7 +39,6 @@ function PublishProjectDialog() { const formComponents = usePublishDialogSelector((s) => s.formComponents); const formState = usePublishDialogSelector((s) => s.formState); const inProgress = usePublishDialogSelector((s) => s.inProgress); - console.debug(); // Check if component is properly initialized and ready for user interaction const isComponentReady = !!context && !!formComponents && !!formState; From 70616cf1ea1aeeaa7ff010f022cdf19801b60245 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 14 Oct 2025 12:11:20 -0500 Subject: [PATCH 58/94] reverting sqlprojservice to optional --- src/publishProject/publishProjectWebViewController.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 6adbc56407..f253bd5e5f 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -39,8 +39,8 @@ export class PublishProjectWebViewController extends FormWebviewController< context: vscode.ExtensionContext, _vscodeWrapper: VscodeWrapper, projectFilePath: string, - sqlProjectsService: SqlProjectsService, - dacFxService: mssql.IDacFxService, + sqlProjectsService?: SqlProjectsService, + dacFxService?: mssql.IDacFxService, deploymentOptions?: mssql.DeploymentOptions, ) { super( From 6c2264ee565347382ab099a29fc22300a60f50f2 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 14 Oct 2025 16:30:50 -0500 Subject: [PATCH 59/94] combobox style fixes --- src/reactviews/index.css | 8 +++++++- .../pages/PublishProject/components/ConnectionSection.tsx | 2 +- .../PublishProject/components/FormFieldComponents.tsx | 5 ----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/reactviews/index.css b/src/reactviews/index.css index 5c5bbd8ddd..732d74a074 100644 --- a/src/reactviews/index.css +++ b/src/reactviews/index.css @@ -23,10 +23,16 @@ border-radius: 2px; } -/* Combobox styling */ +/* Dropdown and Combobox styling */ .fui-Dropdown { border: 1px solid var(--vscode-input-border, transparent); } +.fui-Combobox { + background-color: var(--vscode-settings-dropdownBackground, var(--vscode-dropdown-background)); + color: var(--vscode-settings-dropdownForeground, var(--vscode-dropdown-foreground)); + border: 1px solid var(--vscode-settings-dropdownBorder, var(--vscode-input-border, transparent)); + border-radius: 2px; +} button[role="combobox"] { background-color: var( --vscode-settings-dropdownBackground, diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx index d804074b91..c2b0822437 100644 --- a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -15,7 +15,7 @@ const useStyles = makeStyles({ root: { display: "flex", flexDirection: "column", - gap: "8px", + gap: "16px", maxWidth: "640px", width: "100%", }, diff --git a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx index 6b87d41eeb..5562f644ea 100644 --- a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx +++ b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx @@ -145,11 +145,6 @@ export const renderCombobox = ( freeform={freeform || false} value={value || ""} placeholder={component.placeholder ?? ""} - input={{ - style: { - borderColor: "var(--vscode-input-border, transparent)", - }, - }} onOptionSelect={(_, data) => { if (data.optionValue) { onChange(data.optionValue); From e671f617e3b61915fc696481adad8035e48acb50 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 14 Oct 2025 16:43:56 -0500 Subject: [PATCH 60/94] saving selected DB --- .../PublishProject/components/ConnectionSection.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx index c2b0822437..bfaf977839 100644 --- a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -40,6 +40,17 @@ export const ConnectionSection: React.FC = () => { return undefined; } + const handleDatabaseChange = (value: string) => { + setLocalDatabase(value); + if (databaseComponent) { + publishCtx.formAction({ + propertyName: databaseComponent.propertyName, + isAction: false, + value: value, + }); + } + }; + return (
@@ -57,7 +68,7 @@ export const ConnectionSection: React.FC = () => { /> ), })} - {renderCombobox(databaseComponent, localDatabase, false, setLocalDatabase)} + {renderCombobox(databaseComponent, localDatabase, false, handleDatabaseChange)}
); From 24a1ea17c401dea85cce2293d29eb7e114e00b0d Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 15 Oct 2025 12:30:27 -0500 Subject: [PATCH 61/94] adding tests --- .../publishProjectWebViewController.test.ts | 89 ++++++++++++++++--- 1 file changed, 75 insertions(+), 14 deletions(-) diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 251d134899..6b4249c0d7 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -307,7 +307,7 @@ suite("PublishProjectWebViewController Tests", () => { //#endregion //#region Publish Profile Section Tests - test("selectPublishProfile reducer parses real-world XML profile correctly", async () => { + test("selectPublishProfile reducer parses XML profile and loads server and database correctly", async () => { const controller = createTestController(); await controller.initialized.promise; @@ -369,17 +369,20 @@ suite("PublishProjectWebViewController Tests", () => { expect(mockDacFxService.getOptionsFromProfile.calledOnce).to.be.true; }); - test("savePublishProfile reducer is invoked and triggers save file dialog", async () => { + test("savePublishProfile reducer saves server and database names to file", async () => { const controller = createTestController(); await controller.initialized.promise; - // Set up some form state to save - controller.state.formState.serverName = "localhost"; - controller.state.formState.databaseName = "TestDB"; + // Set up server and database state + controller.state.formState.serverName = "myserver.database.windows.net"; + controller.state.formState.databaseName = "ProductionDB"; + controller.state.formState.sqlCmdVariables = { + EnvironmentName: "Production", + }; // Stub showSaveDialog to simulate user choosing a save location - const savedProfilePath = "c:/profiles/NewProfile.publish.xml"; + const savedProfilePath = "c:/profiles/ProductionProfile.publish.xml"; sandbox.stub(vscode.window, "showSaveDialog").resolves(vscode.Uri.file(savedProfilePath)); // Mock DacFx service @@ -389,18 +392,76 @@ suite("PublishProjectWebViewController Tests", () => { const savePublishProfile = reducerHandlers.get("savePublishProfile"); expect(savePublishProfile, "savePublishProfile reducer should be registered").to.exist; - // Invoke the reducer with an optional default filename - const newState = await savePublishProfile(controller.state, { - event: "TestProject.publish.xml", + // Invoke the reducer + await savePublishProfile(controller.state, { + publishProfileName: "ProductionProfile.publish.xml", }); - // Verify DacFx save was called + // Verify DacFx save was called with correct parameters expect(mockDacFxService.savePublishProfile.calledOnce).to.be.true; - // Verify the state is returned unchanged (savePublishProfile does NOT update path in state) - expect(newState.formState.publishProfilePath).to.equal( - controller.state.formState.publishProfilePath, - ); + const saveCall = mockDacFxService.savePublishProfile.getCall(0); + expect(saveCall.args[0].replace(/\\/g, "/")).to.equal(savedProfilePath); // File path (normalize for cross-platform) + expect(saveCall.args[1]).to.equal("ProductionDB"); // Database name + // Connection string is args[2] + const sqlCmdVariables = saveCall.args[3]; // SQL CMD variables + expect(sqlCmdVariables.get("EnvironmentName")).to.equal("Production"); + }); + //#endregion + + //#region Server and Database Connection Section Tests + test("server and database fields are initialized with correct default values", async () => { + const controller = createTestController("c:/work/MyTestProject.sqlproj"); + + await controller.initialized.promise; + + // Verify server component and default value + const serverComponent = controller.state.formComponents.serverName; + expect(serverComponent).to.exist; + expect(serverComponent.label).to.exist; + expect(serverComponent.required).to.be.true; + expect(controller.state.formState.serverName).to.equal(""); + + // Verify database component and default value (project name) + const databaseComponent = controller.state.formComponents.databaseName; + expect(databaseComponent).to.exist; + expect(databaseComponent.label).to.exist; + expect(databaseComponent.required).to.be.true; + expect(controller.state.formState.databaseName).to.equal("MyTestProject"); + }); + + test("formAction updates server and database names via user interaction", async () => { + const controller = createTestController(); + + await controller.initialized.promise; + + const reducerHandlers = controller["_reducerHandlers"] as Map; + const formAction = reducerHandlers.get("formAction"); + expect(formAction, "formAction reducer should be registered").to.exist; + + // Simulate connection dialog setting server name + await formAction(controller.state, { + event: { + propertyName: "serverName", + value: "localhost,1433", + isAction: false, + }, + }); + + // Verify server name is updated + expect(controller.state.formState.serverName).to.equal("localhost,1433"); + + // Simulate user selecting a database from dropdown + await formAction(controller.state, { + event: { + propertyName: "databaseName", + value: "SelectedDatabase", + isAction: false, + }, + }); + + // Verify database name is updated + expect(controller.state.formState.databaseName).to.equal("SelectedDatabase"); }); //#endregion }); From 8d7bee6f1dfcc3bebdcb865ebd91d245a249b893 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 15 Oct 2025 14:11:58 -0500 Subject: [PATCH 62/94] preserving database options and value on target switch --- .../publishProjectWebViewController.ts | 47 +++++++++++++++++++ src/sharedInterfaces/publishDialog.ts | 2 + 2 files changed, 49 insertions(+) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index b262095d66..01dff076c1 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -420,6 +420,53 @@ export class PublishProjectWebViewController extends FormWebviewController< return activeComponents; } + /** + * Override to handle publish target changes and manage database dropdown options + */ + public async afterSetFormProperty(propertyName: keyof IPublishForm): Promise { + if (propertyName === constants.PublishFormFields.PublishTarget) { + const databaseComponent = + this.state.formComponents[constants.PublishFormFields.DatabaseName]; + + if (!databaseComponent) { + return; + } + + // When switching TO LOCAL_CONTAINER + if (this.state.formState.publishTarget === PublishTarget.LocalContainer) { + // Store current database list and selected value to restore later + if (databaseComponent.options && databaseComponent.options.length > 0) { + this.state.previousDatabaseList = [...databaseComponent.options]; + this.state.previousSelectedDatabase = this.state.formState.databaseName; + } + // Clear database dropdown options for container (freeform only) + databaseComponent.options = []; + + // Reset to project name for container mode + this.state.formState.databaseName = path.basename( + this.state.projectFilePath, + path.extname(this.state.projectFilePath), + ); + } + // When switching TO EXISTING_SERVER + else if (this.state.formState.publishTarget === PublishTarget.ExistingServer) { + // Restore previous database list if it was stored (preserve the list from when user connected) + if (this.state.previousDatabaseList && this.state.previousDatabaseList.length > 0) { + databaseComponent.options = [...this.state.previousDatabaseList]; + + // Restore previously selected database + if (this.state.previousSelectedDatabase) { + this.state.formState.databaseName = this.state.previousSelectedDatabase; + } + } + } + + this.updateState(); + } + + return Promise.resolve(); + } + public updateItemVisibility(state?: PublishDialogState): Promise { const currentState = state || this.state; const target = currentState.formState?.publishTarget; diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 64480ea44c..6e25a63a43 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -64,6 +64,8 @@ export interface PublishDialogState waitingForNewConnection?: boolean; activeServers?: { [connectionUri: string]: { profileName: string; server: string } }; connectionString?: string; + previousDatabaseList?: { displayName: string; value: string }[]; + previousSelectedDatabase?: string; } /** From 0be70129b65bfd3a37a2d22fb6f90a8ef273c908 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 16 Oct 2025 00:31:31 -0500 Subject: [PATCH 63/94] fixing the stabilize conneciton switching issue --- .../publishProjectWebViewController.ts | 127 +++++++----------- src/sharedInterfaces/publishDialog.ts | 2 +- .../publishProjectWebViewController.test.ts | 5 +- 3 files changed, 54 insertions(+), 80 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 01dff076c1..6442cb9e7e 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -66,7 +66,7 @@ export class PublishProjectWebViewController extends FormWebviewController< lastPublishResult: undefined, deploymentOptions: deploymentOptions, waitingForNewConnection: false, - activeServers: {}, + activeConnectionUris: [], } as PublishDialogState, { title: Loc.Title, @@ -102,31 +102,26 @@ export class PublishProjectWebViewController extends FormWebviewController< // Register reducers after initialization this.registerRpcHandlers(); - // Listen for new connections (similar to schema compare) + // Listen for new connections this.registerDisposable( this._connectionManager.onConnectionsChanged(async () => { - // Check if we're waiting for a new connection + // If waiting for a new connection, find which one is new if (this.state.waitingForNewConnection) { - const activeServers = this.getActiveServersList(); - const newConnections = this.findNewConnections( - this.state.activeServers, - activeServers, + const currentUris = Object.keys(this._connectionManager.activeConnections); + + // Find URIs that are in current but not in previous snapshot + const newUris = currentUris.filter( + (uri) => !this.state.activeConnectionUris!.includes(uri), ); - if (newConnections.length > 0) { - // Update active servers first - this.state.activeServers = activeServers; + if (newUris.length > 0) { + // Update snapshot BEFORE auto-populating to prevent re-processing if event fires again + this.state.activeConnectionUris = currentUris; - // Auto-select the first new connection - const newConnectionUri = newConnections[0]; - await this.autoSelectNewConnection(newConnectionUri); + // Auto-populate from the first new connection (this will call updateState internally) + await this.autoSelectNewConnection(newUris[0]); } - } else { - // Update active servers even if not waiting - this.state.activeServers = this.getActiveServersList(); } - - this.updateState(); }), ); @@ -188,6 +183,9 @@ export class PublishProjectWebViewController extends FormWebviewController< /** Registers all reducers in pure (immutable) style */ private registerRpcHandlers(): void { this.registerReducer("openConnectionDialog", async (state: PublishDialogState) => { + // Capture current connections BEFORE opening dialog + state.activeConnectionUris = Object.keys(this._connectionManager.activeConnections); + // Set waiting state to detect new connections state.waitingForNewConnection = true; this.updateState(state); @@ -329,74 +327,49 @@ export class PublishProjectWebViewController extends FormWebviewController< ); } - /** Get the list of active server connections */ - private getActiveServersList(): { - [connectionUri: string]: { profileName: string; server: string }; - } { - const activeServers: { [connectionUri: string]: { profileName: string; server: string } } = - {}; - const activeConnections = this._connectionManager.activeConnections; - Object.keys(activeConnections).forEach((connectionUri) => { - const credentials = activeConnections[connectionUri].credentials as IConnectionProfile; - activeServers[connectionUri] = { - profileName: credentials.profileName ?? "", - server: credentials.server, - }; - }); - return activeServers; - } - - /** Find new connections that were added */ - private findNewConnections( - oldActiveServers: { [connectionUri: string]: { profileName: string; server: string } }, - newActiveServers: { [connectionUri: string]: { profileName: string; server: string } }, - ): string[] { - const newConnections: string[] = []; - for (const connectionUri in newActiveServers) { - if (!(connectionUri in oldActiveServers)) { - newConnections.push(connectionUri); - } - } - return newConnections; - } - /** Auto-select a new connection and populate server/database fields */ private async autoSelectNewConnection(connectionUri: string): Promise { try { - // Get the list of databases for the new connection - const databases = await this._connectionManager.listDatabases(connectionUri); - - // Get the connection profile + // IMPORTANT: Get the connection profile FIRST, before any async calls + // The connection URI may be removed from activeConnections during async operations const connection = this._connectionManager.activeConnections[connectionUri]; const connectionProfile = connection?.credentials as IConnectionProfile; - if (connectionProfile) { - // Update server name - this.state.formState.serverName = connectionProfile.server; + if (!connectionProfile) { + return; // Connection no longer available + } - // Get connection string (include password for publishing) - const connectionString = await this._connectionManager.getConnectionString( - connectionUri, - true, // includePassword - true, // includeApplicationName - ); - this.state.connectionString = connectionString; - - // Update database dropdown options - const databaseComponent = - this.state.formComponents[constants.PublishFormFields.DatabaseName]; - if (databaseComponent) { - databaseComponent.options = databases.map((db) => ({ - displayName: db, - value: db, - })); - } + // Update server name immediately + this.state.formState.serverName = connectionProfile.server; - // Optionally select the first database if available - if (databases.length > 0 && !this.state.formState.databaseName) { - this.state.formState.databaseName = databases[0]; - } + // Get connection string first + const connectionString = await this._connectionManager.getConnectionString( + connectionUri, + true, // includePassword + true, // includeApplicationName + ); + this.state.connectionString = connectionString; + + // Get databases + const databases = await this._connectionManager.listDatabases(connectionUri); + + // Update database dropdown options + const databaseComponent = + this.state.formComponents[constants.PublishFormFields.DatabaseName]; + if (databaseComponent) { + databaseComponent.options = databases.map((db) => ({ + displayName: db, + value: db, + })); + } + + // Optionally select the first database if available + if (databases.length > 0 && !this.state.formState.databaseName) { + this.state.formState.databaseName = databases[0]; } + + // Update UI immediately to reflect the new connection + this.updateState(); } catch { // Silently fail - connection issues are handled elsewhere } finally { diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 6e25a63a43..590ffd0dbb 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -62,7 +62,7 @@ export interface PublishDialogState deploymentOptions?: mssql.DeploymentOptions; projectProperties?: ProjectProperties; waitingForNewConnection?: boolean; - activeServers?: { [connectionUri: string]: { profileName: string; server: string } }; + activeConnectionUris?: string[]; connectionString?: string; previousDatabaseList?: { displayName: string; value: string }[]; previousSelectedDatabase?: string; diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 6b4249c0d7..70577a1d6f 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -41,9 +41,10 @@ suite("PublishProjectWebViewController Tests", () => { mockSqlProjectsService = {}; mockDacFxService = {}; mockConnectionManager = { - onConnectionsChanged: sandbox.stub().returns({ dispose: () => {} }), + onConnectionsChanged: sinon.stub(), activeConnections: {}, - listDatabases: sandbox.stub().resolves([]), + listDatabases: sinon.stub().resolves([]), + getConnectionString: sinon.stub().returns(""), }; }); From 9c46c1653e8c9dbdb073519917550b5cac476fd0 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 16 Oct 2025 00:47:33 -0500 Subject: [PATCH 64/94] adding placeholder --- localization/l10n/bundle.l10n.json | 2 +- localization/xliff/vscode-mssql.xlf | 9 ++++++--- src/constants/locConstants.ts | 2 +- src/publishProject/formComponentHelpers.ts | 1 + 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index d1282ea2b8..a316a58a00 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1429,7 +1429,7 @@ "General": "General", "Publish Project": "Publish Project", "Publish Profile": "Publish Profile", - "Select or enter a publish profile": "Select or enter a publish profile", + "Load profile...": "Load profile...", "Save As": "Save As", "Publish Settings File": "Publish Settings File", "Database name is required": "Database name is required", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index b78bf029c4..7253327aad 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -1894,9 +1894,15 @@ Load + + Load Profile... + Load from Connection String + + Load profile... + Load source, target, and options saved in an .scmp file @@ -2866,9 +2872,6 @@ Select new permission for extension: '{0}' {0} is the extension name - - Select or enter a publish profile - Select profile to remove diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index f451fafdcc..ae341fb3b2 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1282,7 +1282,7 @@ export class TableDesigner { export class PublishProject { public static Title = l10n.t("Publish Project"); public static PublishProfileLabel = l10n.t("Publish Profile"); - public static PublishProfilePlaceholder = l10n.t("Select or enter a publish profile"); + public static PublishProfilePlaceholder = l10n.t("Load profile..."); public static SelectPublishProfile = l10n.t("Select Profile"); public static SaveAs = l10n.t("Save As"); public static PublishSettingsFile = l10n.t("Publish Settings File"); diff --git a/src/publishProject/formComponentHelpers.ts b/src/publishProject/formComponentHelpers.ts index 5931068a9d..8f84040402 100644 --- a/src/publishProject/formComponentHelpers.ts +++ b/src/publishProject/formComponentHelpers.ts @@ -75,6 +75,7 @@ export function generatePublishFormComponents( publishProfilePath: { propertyName: constants.PublishFormFields.PublishProfilePath, label: Loc.PublishProfileLabel, + placeholder: Loc.PublishProfilePlaceholder, required: false, type: FormItemType.Input, }, From 2f810ec2ac3741c0d4c95bbc4a6ade9c6efc31e1 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 16 Oct 2025 00:52:13 -0500 Subject: [PATCH 65/94] LOC --- localization/xliff/vscode-mssql.xlf | 3 --- 1 file changed, 3 deletions(-) diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 7253327aad..a26b37a296 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -1894,9 +1894,6 @@ Load - - Load Profile... - Load from Connection String From 890c7081e50216b23bb65ae7808780c69d3bb331 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 16 Oct 2025 01:05:24 -0500 Subject: [PATCH 66/94] making conn string empty for container --- src/publishProject/publishProjectWebViewController.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 6442cb9e7e..62af4ffc3a 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -294,7 +294,12 @@ export class PublishProjectWebViewController extends FormWebviewController< // Save the profile using DacFx service try { const databaseName = state.formState.databaseName || projectName; - const connectionString = state.connectionString || ""; + // Connection string depends on publish target: + // - For container targets: empty string (no server connection) + const connectionString = + state.formState.publishTarget === PublishTarget.LocalContainer + ? "" + : state.connectionString || ""; const sqlCmdVariables = new Map( Object.entries(state.formState.sqlCmdVariables || {}), ); @@ -420,6 +425,9 @@ export class PublishProjectWebViewController extends FormWebviewController< this.state.projectFilePath, path.extname(this.state.projectFilePath), ); + + // Clear connection string when switching to container target + this.state.connectionString = undefined; } // When switching TO EXISTING_SERVER else if (this.state.formState.publishTarget === PublishTarget.ExistingServer) { From 5ffb1e0bfc5d99a0a8f86aa1af7369fc002de485 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 16 Oct 2025 11:29:29 -0500 Subject: [PATCH 67/94] addressing copilot code suggestions --- .../publishProjectWebViewController.ts | 13 +++++++++---- .../components/FormFieldComponents.tsx | 14 ++++++++++---- src/sharedInterfaces/telemetry.ts | 1 + 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 62af4ffc3a..3c7fe1c668 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -340,8 +340,8 @@ export class PublishProjectWebViewController extends FormWebviewController< const connection = this._connectionManager.activeConnections[connectionUri]; const connectionProfile = connection?.credentials as IConnectionProfile; - if (!connectionProfile) { - return; // Connection no longer available + if (!connectionProfile || !connectionProfile.server) { + return; // Connection no longer available or invalid } // Update server name immediately @@ -375,8 +375,13 @@ export class PublishProjectWebViewController extends FormWebviewController< // Update UI immediately to reflect the new connection this.updateState(); - } catch { - // Silently fail - connection issues are handled elsewhere + } catch (err) { + // Log the error for diagnostics + sendActionEvent( + TelemetryViews.SqlProjects, + TelemetryActions.PublishProjectConnectionError, + { error: err instanceof Error ? err.message : String(err) }, + ); } finally { // Reset the waiting state this.state.waitingForNewConnection = false; diff --git a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx index 5562f644ea..c8bc959e30 100644 --- a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx +++ b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx @@ -23,7 +23,9 @@ const getValidationState = ( return validation ? (validation.isValid ? "none" : "error") : "none"; }; -// Generic Input Field - can be used for text, number, or password fields +/* + * Generic Input Field - can be used for text, number, or password fields + */ export const renderInput = ( component: PublishDialogFormItemSpec | undefined, value: string, @@ -79,7 +81,9 @@ export const renderInput = ( ); }; -// Generic Dropdown Field - can be used for any dropdown selection +/* + * Generic Dropdown Field - can be used for any dropdown selection + */ export const renderDropdown = ( component: PublishDialogFormItemSpec | undefined, value: string | undefined, @@ -122,7 +126,9 @@ export const renderDropdown = ( ); }; -// Generic Combobox Field - can be used for editable dropdowns (allows custom text input) +/* + * Generic Combobox Field - can be used for editable dropdowns (allows custom text input) + */ export const renderCombobox = ( component: PublishDialogFormItemSpec | undefined, value: string | undefined, @@ -152,7 +158,7 @@ export const renderCombobox = ( }} onChange={(event) => { // Allow custom text input - onChange((event.target as HTMLInputElement).value); + onChange(event.currentTarget.value); }}> {component.options.map( (opt: { value: string; displayName: string; color?: string }, i: number) => ( diff --git a/src/sharedInterfaces/telemetry.ts b/src/sharedInterfaces/telemetry.ts index cda85af332..05bc3b086f 100644 --- a/src/sharedInterfaces/telemetry.ts +++ b/src/sharedInterfaces/telemetry.ts @@ -81,6 +81,7 @@ export enum TelemetryActions { SurveySubmit = "SurveySubmit", PublishProfileLoaded = "PublishProfileLoaded", PublishProfileSaved = "PublishProfileSaved", + PublishProjectConnectionError = "PublishProjectConnection", SaveResults = "SaveResults", CopyResults = "CopyResults", CopyResultsHeaders = "CopyResultsHeaders", From 6267375524f8d658bbda6fa56e38d8aeb6aee88b Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Thu, 16 Oct 2025 22:55:25 -0500 Subject: [PATCH 68/94] advanced drawer initial implementation --- src/constants/locConstants.ts | 4 + .../publishProjectWebViewController.ts | 121 ++++++++++++ src/reactviews/common/locConstants.ts | 4 + .../advancedDeploymentOptionsDrawer.tsx | 179 ++++++++++++++++++ .../pages/PublishProject/publishProject.tsx | 59 +++--- .../publishProjectStateProvider.tsx | 2 + src/sharedInterfaces/publishDialog.ts | 18 +- 7 files changed, 362 insertions(+), 25 deletions(-) create mode 100644 src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index bdb79f5402..023790d323 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1298,6 +1298,10 @@ export class PublishProject { public static PublishTargetNewAzureServer = l10n.t("New Azure SQL logical server (Preview)"); public static GenerateScript = l10n.t("Generate Script"); public static Publish = l10n.t("Publish"); + public static AdvancedOptions = l10n.t("Advanced..."); + public static AdvancedPublishSettings = l10n.t("Advanced Deployment Options"); + public static GeneralOptions = l10n.t("General Options"); + public static ExcludeObjectTypes = l10n.t("Exclude Object Types"); public static SqlServerPortNumber = l10n.t("SQL Server port number"); public static SqlServerAdminPassword = l10n.t("SQL Server admin password"); public static SqlServerAdminPasswordConfirm = l10n.t("Confirm SQL Server admin password"); diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 3c7fe1c668..56fef0ee55 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -99,6 +99,9 @@ export class PublishProjectWebViewController extends FormWebviewController< this.state.deploymentOptions.excludeObjectTypes.value = []; } + // Create grouped advanced options for the UI + this.createGroupedAdvancedOptions(); + // Register reducers after initialization this.registerRpcHandlers(); @@ -206,6 +209,59 @@ export class PublishProjectWebViewController extends FormWebviewController< return state; }); + this.registerReducer( + "updateDeploymentOption", + async (state: PublishDialogState, payload: { optionName: string; value: boolean }) => { + // Update a specific deployment option value + if (!state.deploymentOptions) { + return state; + } + + const updatedOptions = { ...state.deploymentOptions }; + + // Check if this is a boolean option + if (updatedOptions.booleanOptionsDictionary?.[payload.optionName]) { + updatedOptions.booleanOptionsDictionary[payload.optionName] = { + ...updatedOptions.booleanOptionsDictionary[payload.optionName], + value: payload.value, + }; + } + // Check if this is an exclude object type + else if (updatedOptions.objectTypesDictionary?.[payload.optionName]) { + // excludeObjectTypes.value is a string array of excluded types + const currentExcluded = updatedOptions.excludeObjectTypes?.value || []; + let newExcluded: string[]; + + if (payload.value) { + // Add to excluded list if not already there + newExcluded = currentExcluded.includes(payload.optionName) + ? currentExcluded + : [...currentExcluded, payload.optionName]; + } else { + // Remove from excluded list + newExcluded = currentExcluded.filter((type) => type !== payload.optionName); + } + + updatedOptions.excludeObjectTypes = { + ...updatedOptions.excludeObjectTypes, + value: newExcluded, + }; + } + + const newState = { + ...state, + deploymentOptions: updatedOptions, + }; + + // Regenerate grouped options after update + this.state = newState; + this.createGroupedAdvancedOptions(); + newState.groupedAdvancedOptions = this.state.groupedAdvancedOptions; + + return newState; + }, + ); + this.registerReducer("selectPublishProfile", async (state: PublishDialogState) => { const projectFolderPath = state.projectProperties?.projectFolderPath; @@ -475,4 +531,69 @@ export class PublishProjectWebViewController extends FormWebviewController< return Promise.resolve(); } + + /** + * Creates grouped advanced options from deployment options + */ + private createGroupedAdvancedOptions(): void { + if (!this.state.deploymentOptions) { + this.state.groupedAdvancedOptions = []; + return; + } + + const groups: { + key: string; + label: string; + entries: { key: string; displayName: string; description: string; value: boolean }[]; + }[] = []; + + // General Options group + if (this.state.deploymentOptions.booleanOptionsDictionary) { + const generalEntries = Object.entries( + this.state.deploymentOptions.booleanOptionsDictionary, + ).map(([key, option]) => ({ + key, + displayName: option.displayName, + description: option.description, + value: option.value, + })); + + if (generalEntries.length > 0) { + groups.push({ + key: "General", + label: Loc.GeneralOptions, + entries: generalEntries, + }); + } + } + + // Exclude Object Types group + if (this.state.deploymentOptions.objectTypesDictionary) { + // excludeObjectTypes.value is an array of excluded type names + // We need to show ALL object types with checkboxes (checked = excluded) + const excludedTypes = this.state.deploymentOptions.excludeObjectTypes?.value || []; + + const excludeEntries = Object.entries( + this.state.deploymentOptions.objectTypesDictionary, + ) + .map(([key, displayName]) => ({ + key, + displayName: displayName || key, + description: "", + value: Array.isArray(excludedTypes) ? excludedTypes.includes(key) : false, + })) + .filter((entry) => entry.displayName) // Only include entries with display names + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + + if (excludeEntries.length > 0) { + groups.push({ + key: "Exclude", + label: Loc.ExcludeObjectTypes, + entries: excludeEntries, + }); + } + } + + this.state.groupedAdvancedOptions = groups; + } } diff --git a/src/reactviews/common/locConstants.ts b/src/reactviews/common/locConstants.ts index 72f37a7e1d..5d18eeea45 100644 --- a/src/reactviews/common/locConstants.ts +++ b/src/reactviews/common/locConstants.ts @@ -906,6 +906,10 @@ export class LocConstants { SaveAs: l10n.t("Save As..."), generateScript: l10n.t("Generate Script"), publish: l10n.t("Publish"), + advancedOptions: l10n.t("Advanced..."), + advancedPublishSettings: l10n.t("Advanced Deployment Options"), + generalOptions: l10n.t("General Options"), + excludeObjectTypes: l10n.t("Exclude Object Types"), }; } diff --git a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx new file mode 100644 index 0000000000..f71dbf39bf --- /dev/null +++ b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel, + Button, + DrawerBody, + DrawerHeader, + DrawerHeaderTitle, + OverlayDrawer, + SearchBox, + Checkbox, + Tooltip, + makeStyles, + shorthands, +} from "@fluentui/react-components"; +import { Dismiss24Regular, InfoRegular } from "@fluentui/react-icons"; +import { useContext, useState } from "react"; +import { LocConstants } from "../../../common/locConstants"; +import { PublishProjectContext } from "../publishProjectStateProvider"; +import { usePublishDialogSelector } from "../publishDialogSelector"; +import { useAccordionStyles } from "../../../common/styles"; + +const useStyles = makeStyles({ + optionsList: { + display: "flex", + flexDirection: "column", + gap: "4px", + }, + optionItem: { + display: "flex", + alignItems: "center", + gap: "8px", + ...shorthands.padding("4px", "0px"), + }, + optionLabel: { + flex: 1, + cursor: "pointer", + }, + infoIcon: { + color: "var(--colorNeutralForeground3)", + cursor: "help", + }, +}); + +export const AdvancedDeploymentOptionsDrawer = ({ + isAdvancedDrawerOpen, + setIsAdvancedDrawerOpen, +}: { + isAdvancedDrawerOpen: boolean; + setIsAdvancedDrawerOpen: React.Dispatch>; +}) => { + const classes = useStyles(); + const accordionStyles = useAccordionStyles(); + const context = useContext(PublishProjectContext); + const [searchText, setSearchText] = useState(""); + const [userOpenedSections, setUserOpenedSections] = useState(["General"]); + const loc = LocConstants.getInstance(); + + // Get grouped options from state (prepared by controller) + const optionGroups = usePublishDialogSelector((s) => s.groupedAdvancedOptions ?? []); + + const handleOptionChange = (optionName: string, checked: boolean) => { + context?.updateDeploymentOption(optionName, checked); + }; + + // Helper to check if option matches search + const isOptionVisible = (option: { + key: string; + displayName: string; + description: string; + value: boolean; + }) => { + if (!searchText) return true; + + const lowerSearch = searchText.toLowerCase(); + return ( + option.displayName.toLowerCase().includes(lowerSearch) || + option.key.toLowerCase().includes(lowerSearch) || + option.description.toLowerCase().includes(lowerSearch) + ); + }; + + // Render a single option (same for all groups) + const renderOption = (option: { + key: string; + displayName: string; + description: string; + value: boolean; + }) => { + return ( +
+ handleOptionChange(option.key, data.checked === true)} + label={ + handleOptionChange(option.key, !option.value)}> + {option.displayName} + + } + /> + {option.description && ( + + + + )} +
+ ); + }; + + if (!context) { + return undefined; + } + + return ( + setIsAdvancedDrawerOpen(open)}> + + } + onClick={() => setIsAdvancedDrawerOpen(false)} + /> + }> + {loc.publishProject.advancedPublishSettings} + + + + + setSearchText(data.value ?? "")} + value={searchText} + /> + + { + if (!searchText) { + setUserOpenedSections(data.openItems as string[]); + } + }} + openItems={searchText ? optionGroups.map((g) => g.key) : userOpenedSections}> + {optionGroups.map((group) => ( + + {group.label} + +
+ {group.entries + .filter((option) => isOptionVisible(option)) + .map((option) => renderOption(option))} +
+
+
+ ))} +
+
+
+ ); +}; diff --git a/src/reactviews/pages/PublishProject/publishProject.tsx b/src/reactviews/pages/PublishProject/publishProject.tsx index a2a4f904de..a908f8aca2 100644 --- a/src/reactviews/pages/PublishProject/publishProject.tsx +++ b/src/reactviews/pages/PublishProject/publishProject.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { useContext } from "react"; +import { useContext, useState } from "react"; import { Button, makeStyles } from "@fluentui/react-components"; import { useFormStyles } from "../../common/forms/form.component"; import { PublishProjectContext } from "./publishProjectStateProvider"; @@ -13,19 +13,14 @@ import { LocConstants } from "../../common/locConstants"; import { PublishProfileField } from "./components/PublishProfileSection"; import { PublishTargetSection } from "./components/PublishTargetSection"; import { ConnectionSection } from "./components/ConnectionSection"; +import { AdvancedDeploymentOptionsDrawer } from "./components/advancedDeploymentOptionsDrawer"; const useStyles = makeStyles({ root: { padding: "12px" }, - footer: { - marginTop: "8px", - display: "flex", - justifyContent: "flex-end", - gap: "12px", - alignItems: "center", - maxWidth: "640px", - width: "100%", - paddingTop: "12px", - borderTop: "1px solid transparent", + rightButton: { + width: "150px", + marginLeft: "10px", + marginRight: "0px", }, }); @@ -34,6 +29,7 @@ function PublishProjectDialog() { const formStyles = useFormStyles(); const loc = LocConstants.getInstance().publishProject; const context = useContext(PublishProjectContext); + const [isAdvancedDrawerOpen, setIsAdvancedDrawerOpen] = useState(false); // Select pieces of state needed for this component const formComponents = usePublishDialogSelector((s) => s.formComponents); @@ -94,24 +90,39 @@ function PublishProjectDialog() { -
+
- +
+ + +
+ +
); diff --git a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx index feba7bea0b..df69d2ace0 100644 --- a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx +++ b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx @@ -41,6 +41,8 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } savePublishProfile: (publishProfileName: string) => extensionRpc.action("savePublishProfile", { publishProfileName }), openConnectionDialog: () => extensionRpc.action("openConnectionDialog"), + updateDeploymentOption: (optionName: string, value: boolean) => + extensionRpc.action("updateDeploymentOption", { optionName, value }), extensionRpc, }), [extensionRpc], diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 590ffd0dbb..66d655c031 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -66,6 +66,20 @@ export interface PublishDialogState connectionString?: string; previousDatabaseList?: { displayName: string; value: string }[]; previousSelectedDatabase?: string; + groupedAdvancedOptions?: DeploymentOptionGroup[]; +} + +export interface DeploymentOptionGroup { + key: string; + label: string; + entries: DeploymentOptionEntry[]; +} + +export interface DeploymentOptionEntry { + key: string; + displayName: string; + description: string; + value: boolean; } /** @@ -90,10 +104,10 @@ export interface PublishDialogReducers extends FormReducers { publishProfilePath?: string; }; generatePublishScript: {}; - openPublishAdvanced: {}; selectPublishProfile: {}; savePublishProfile: { publishProfileName: string }; openConnectionDialog: {}; + updateDeploymentOption: { optionName: string; value: boolean }; } /** @@ -119,4 +133,6 @@ export interface PublishProjectProvider { savePublishProfile(publishProfileName: string): void; /** Open connection dialog to select server and database */ openConnectionDialog(): void; + /** Update a specific deployment option */ + updateDeploymentOption(optionName: string, value: boolean): void; } From 722110e8b225a70a484d414ee22dc4c519496e39 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Fri, 17 Oct 2025 16:15:43 -0500 Subject: [PATCH 69/94] Loc --- localization/l10n/bundle.l10n.json | 3 +++ localization/xliff/vscode-mssql.xlf | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 9932eb5dea..c742616cf6 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -643,6 +643,9 @@ "Processing include or exclude all differences operation.": "Processing include or exclude all differences operation.", "Select Profile": "Select Profile", "Save As...": "Save As...", + "Advanced...": "Advanced...", + "Advanced Deployment Options": "Advanced Deployment Options", + "Exclude Object Types": "Exclude Object Types", "Create New Connection Group": "Create New Connection Group", "Edit Connection Group: {0}/{0} is the name of the connection group being edited": { "message": "Edit Connection Group: {0}", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 4e8beaaefe..3745491633 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -129,9 +129,15 @@ Advanced Connection Settings + + Advanced Deployment Options + Advanced Options + + Advanced... + All permissions for extensions to access your connections have been cleared. @@ -1279,6 +1285,9 @@ Excel + + Exclude Object Types + Execute From a33be733b9957a22f23c734be7e0d3d1db167f11 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Fri, 17 Oct 2025 16:56:36 -0500 Subject: [PATCH 70/94] using onsuccessfulconneciton responce --- .../publishProjectWebViewController.ts | 26 ++++--------------- src/sharedInterfaces/publishDialog.ts | 1 - 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 3c7fe1c668..c506d38224 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -66,7 +66,6 @@ export class PublishProjectWebViewController extends FormWebviewController< lastPublishResult: undefined, deploymentOptions: deploymentOptions, waitingForNewConnection: false, - activeConnectionUris: [], } as PublishDialogState, { title: Loc.Title, @@ -102,25 +101,13 @@ export class PublishProjectWebViewController extends FormWebviewController< // Register reducers after initialization this.registerRpcHandlers(); - // Listen for new connections + // Listen for successful connections this.registerDisposable( - this._connectionManager.onConnectionsChanged(async () => { - // If waiting for a new connection, find which one is new + this._connectionManager.onSuccessfulConnection(async (event) => { + // Only auto-populate if waiting for a new connection if (this.state.waitingForNewConnection) { - const currentUris = Object.keys(this._connectionManager.activeConnections); - - // Find URIs that are in current but not in previous snapshot - const newUris = currentUris.filter( - (uri) => !this.state.activeConnectionUris!.includes(uri), - ); - - if (newUris.length > 0) { - // Update snapshot BEFORE auto-populating to prevent re-processing if event fires again - this.state.activeConnectionUris = currentUris; - - // Auto-populate from the first new connection (this will call updateState internally) - await this.autoSelectNewConnection(newUris[0]); - } + // Auto-populate from the new connection (this will call updateState internally) + await this.autoSelectNewConnection(event.fileUri); } }), ); @@ -183,9 +170,6 @@ export class PublishProjectWebViewController extends FormWebviewController< /** Registers all reducers in pure (immutable) style */ private registerRpcHandlers(): void { this.registerReducer("openConnectionDialog", async (state: PublishDialogState) => { - // Capture current connections BEFORE opening dialog - state.activeConnectionUris = Object.keys(this._connectionManager.activeConnections); - // Set waiting state to detect new connections state.waitingForNewConnection = true; this.updateState(state); diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 590ffd0dbb..8fb9ca7422 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -62,7 +62,6 @@ export interface PublishDialogState deploymentOptions?: mssql.DeploymentOptions; projectProperties?: ProjectProperties; waitingForNewConnection?: boolean; - activeConnectionUris?: string[]; connectionString?: string; previousDatabaseList?: { displayName: string; value: string }[]; previousSelectedDatabase?: string; From 39bb3f3a17d0dd12d177a30dc79a4fdf0d626dfc Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Mon, 20 Oct 2025 23:18:11 -0500 Subject: [PATCH 71/94] Loc --- localization/xliff/vscode-mssql.xlf | 4 ---- 1 file changed, 4 deletions(-) diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 7b53368524..527b0177aa 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -1701,10 +1701,6 @@ Hue - - I accept the <a href="{0}" target="_blank" rel="noopener noreferrer">Microsoft SQL Server License Agreement</a> - {0} is the hyperlink URL to the Microsoft SQL Server License Agreement used in an HTML anchor tag - I have read the summary and understand the potential risks. From 86ae055b281f89566865c92eee7af8bff1eb32ea Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Mon, 20 Oct 2025 23:31:17 -0500 Subject: [PATCH 72/94] removing dockerUtils file --- src/publishProject/dockerUtils.ts | 232 ------------------------------ 1 file changed, 232 deletions(-) delete mode 100644 src/publishProject/dockerUtils.ts diff --git a/src/publishProject/dockerUtils.ts b/src/publishProject/dockerUtils.ts deleted file mode 100644 index 03f80b6769..0000000000 --- a/src/publishProject/dockerUtils.ts +++ /dev/null @@ -1,232 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as constants from "../constants/constants"; -import { targetPlatformToVersion } from "./projectUtils"; - -export interface AgreementInfoLink { - text: string; - url: string; -} -export interface AgreementInfo { - link: AgreementInfoLink; -} -export interface DockerImageInfo { - name: string; - displayName: string; - agreementInfo: AgreementInfo; - tagsUrl: string; - defaultTag: string; -} - -/** - * Returns SQL version number from docker image name which is in the beginning of the image name - * @param imageName docker image name - * @param regex optional regex to use for finding the version - * @returns SQL server version - */ -function findSqlVersionInImageName(imageName: string, regex?: RegExp): number | undefined { - // Regex to find the version in the beginning of the image name - // e.g. 2017-CU16-ubuntu, 2019-latest - if (!regex) { - regex = new RegExp("^([0-9]+)[-].+$"); - } - - if (regex.test(imageName)) { - const finds = regex.exec(imageName); - if (finds) { - // 0 is the full match and 1 is the number with pattern inside the first () - return +finds[1]; - } - } - return undefined; -} - -// Extract a version year from a target platform string -function findSqlVersionInTargetPlatform(target: string | undefined): number | undefined { - if (!target) { - return undefined; - } - const regex = new RegExp("([0-9]+)$"); - return findSqlVersionInImageName(target, regex); -} - -/* - * Returns the target platform string for a given SQL version number - */ -export function getTargetPlatformFromVersion(version: string): string { - return Array.from(targetPlatformToVersion.keys()).filter( - (k) => targetPlatformToVersion.get(k) === version, - )[0]; -} - -/** - * Returns the list of image tags for given target - * @param rawTags docker image tags - * @param imageInfo docker image info - * @param target project target version - * @param defaultTagFirst whether the default tag should be the first entry in the array - * @returns image tags - */ -export function filterAndSortTags( - rawTags: string[], - imageInfo: DockerImageInfo, - target: string, - defaultTagFirst?: boolean, -): string[] { - const versionToImageTags: Map = new Map(); - let imageTags: string[] | undefined = []; - if (rawTags) { - // Create a map for version and tags and find the max version in the list - let defaultVersion = 0; - let maxVersionNumber: number = defaultVersion; - (rawTags as string[]).forEach((imageTag) => { - const version = findSqlVersionInImageName(imageTag) || defaultVersion; - let tags = versionToImageTags.has(version) ? versionToImageTags.get(version) : []; - tags = tags ?? []; - tags = tags?.concat(imageTag); - versionToImageTags.set(version, tags); - maxVersionNumber = version && version > maxVersionNumber ? version : maxVersionNumber; - }); - - // Find the version maps to the target framework and default to max version in the tags - const targetVersion = - findSqlVersionInTargetPlatform(getTargetPlatformFromVersion(target)) || - maxVersionNumber; - - // Get the image tags with no version of the one that matches project platform - versionToImageTags.forEach((tags: string[], version: number) => { - if (version === defaultVersion || version >= targetVersion) { - imageTags = imageTags?.concat(tags); - } - }); - - imageTags = imageTags ?? []; - imageTags = imageTags.sort((a, b) => - a.indexOf(constants.dockerImageDefaultTag) > 0 ? -1 : a.localeCompare(b), - ); - - if (defaultTagFirst) { - const defaultIndex = imageTags.findIndex((i) => i === imageInfo.defaultTag); - if (defaultIndex > -1) { - imageTags.splice(defaultIndex, 1); - imageTags.unshift(imageInfo.defaultTag); - } - } - } - return imageTags; -} - -export function getDockerBaseImage(target: string, azureTargetVersion?: string): DockerImageInfo { - return { - name: `${constants.sqlServerDockerRegistry}/${constants.sqlServerDockerRepository}`, - displayName: - azureTargetVersion && target === azureTargetVersion - ? constants.AzureSqlDbFullDockerImageName - : constants.SqlServerDockerImageName, - agreementInfo: { - link: { - text: "Microsoft SQL Server License Agreement", - url: constants.sqlServerEulaLink, - }, - }, - tagsUrl: `https://${constants.sqlServerDockerRegistry}/v2/${constants.sqlServerDockerRepository}/tags/list`, - defaultTag: constants.dockerImageDefaultTag, - }; -} - -/** - * Loads Docker tags for a given target version and updates form component options - * @param targetVersion project target version - * @param tagComponent form component to update with tags - * @param formState current form state to set default tag if needed - */ -export async function loadDockerTags( - targetVersion: string, - tagComponent: { options?: { value: string; displayName: string }[] }, - formState: { containerImageTag?: string }, -): Promise { - const baseImage = getDockerBaseImage(targetVersion, undefined); - let tags: string[] = []; - - try { - // Security: Validate URL is from trusted Microsoft registry - const url = new URL(baseImage.tagsUrl); - if (!url.hostname.endsWith(".microsoft.com") && url.hostname !== "mcr.microsoft.com") { - console.warn("Untrusted registry URL blocked:", baseImage.tagsUrl); - return; - } - - // Create AbortController for timeout control - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - - try { - const resp = await fetch(baseImage.tagsUrl, { - method: "GET", - signal: controller.signal, - headers: { - Accept: "application/json", - "User-Agent": "vscode-mssql-extension", - }, - // Security: Prevent credentials from being sent - credentials: "omit", - // Security: Follow redirects only to same origin - redirect: "follow", - }); - - clearTimeout(timeoutId); - - if (!resp.ok) { - console.warn(`Failed to fetch Docker tags: ${resp.status} ${resp.statusText}`); - return; - } - - // Security: Check content type - const contentType = resp.headers.get("content-type"); - if (!contentType || !contentType.includes("application/json")) { - console.warn("Invalid content type for Docker tags response:", contentType); - return; - } - - const json = await resp.json(); - if (json?.tags && Array.isArray(json.tags)) { - // Security: Validate tag format to prevent injection - tags = (json.tags as string[]).filter( - (tag) => - typeof tag === "string" && - /^[a-zA-Z0-9._-]+$/.test(tag) && - tag.length <= 128, - ); - } - } catch (fetchError: unknown) { - clearTimeout(timeoutId); - if (fetchError instanceof Error && fetchError.name === "AbortError") { - console.warn("Docker tags request timed out"); - } else { - console.warn("Network error fetching Docker tags:", fetchError); - } - return; - } - } catch (urlError: unknown) { - console.warn("Invalid Docker tags URL:", urlError); - return; - } - - const imageTags = filterAndSortTags(tags, baseImage, targetVersion, true); - - // Update containerImageTag component options - if (tagComponent) { - tagComponent.options = imageTags.map((t) => ({ value: t, displayName: t })); - - // Set default tag if none selected - if ( - imageTags.length > 0 && - (!formState.containerImageTag || !imageTags.includes(formState.containerImageTag)) - ) { - formState.containerImageTag = imageTags[0]; - } - } -} From 945c504be1842e234decbd5596905d7e5d3bba66 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 21 Oct 2025 00:29:19 -0500 Subject: [PATCH 73/94] update read sqlcmd variabled from profile using the xmlDom parser --- package.json | 3 ++- src/publishProject/projectUtils.ts | 39 +++++++++++++++++++++++------- yarn.lock | 18 ++++++++------ 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 9fcfd0e65a..0790d6c210 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "@types/tunnel": "0.0.1", "@types/vscode": "1.98.0", "@types/vscode-webview": "^1.57.5", + "@types/xmldom": "^0.1.34", "@typescript-eslint/eslint-plugin": "^8.7.0", "@typescript-eslint/parser": "^8.7.0", "@vscode/l10n": "^0.0.18", @@ -150,7 +151,6 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.34.0", "xliff": "^6.2.1", - "xml2js": "^0.6.2", "yargs": "^17.7.2" }, "dependencies": { @@ -162,6 +162,7 @@ "@azure/msal-node": "^2.12.0", "@microsoft/ads-extension-telemetry": "^3.0.2", "@microsoft/vscode-azext-azureauth": "^4.1.1", + "@xmldom/xmldom": "^0.8.11", "axios": "^1.12.0", "dotenv": "^16.4.5", "error-ex": "^1.3.0", diff --git a/src/publishProject/projectUtils.ts b/src/publishProject/projectUtils.ts index 896e956ec1..5f794a2a3e 100644 --- a/src/publishProject/projectUtils.ts +++ b/src/publishProject/projectUtils.ts @@ -8,6 +8,7 @@ import * as mssql from "vscode-mssql"; import * as constants from "../constants/constants"; import { SqlProjectsService } from "../services/sqlProjectsService"; import { promises as fs } from "fs"; +import { DOMParser } from "@xmldom/xmldom"; /** * Checks if preview features are enabled in VS Code settings for SQL Database Projects. @@ -149,17 +150,37 @@ export function validateSqlServerPortNumber(port: number): boolean { */ export function readSqlCmdVariables(profileText: string): { [key: string]: string } { const sqlCmdVariables: { [key: string]: string } = {}; - const sqlCmdVarRegex = - /\s*(.*?)<\/Value>\s*<\/SqlCmdVariable>/gs; - let match; - while ((match = sqlCmdVarRegex.exec(profileText)) !== undefined) { - if (!match) { - break; + + try { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(profileText, "application/xml"); + + // Get all SqlCmdVariable elements + const sqlCmdVarElements = xmlDoc.documentElement.getElementsByTagName("SqlCmdVariable"); + + for (let i = 0; i < sqlCmdVarElements.length; i++) { + const sqlCmdVar = sqlCmdVarElements[i]; + const varName = sqlCmdVar.getAttribute("Include"); + + if (varName) { + // Look for Value first (preferred for publish profiles), then DefaultValue + let varValue = ""; + const valueElements = sqlCmdVar.getElementsByTagName("Value"); + const defaultValueElements = sqlCmdVar.getElementsByTagName("DefaultValue"); + + if (valueElements.length > 0 && valueElements[0].firstChild) { + varValue = valueElements[0].firstChild.nodeValue || ""; + } else if (defaultValueElements.length > 0 && defaultValueElements[0].firstChild) { + varValue = defaultValueElements[0].firstChild.nodeValue || ""; + } + + sqlCmdVariables[varName] = varValue; + } } - const varName = match[1]; - const varValue = match[2]; - sqlCmdVariables[varName] = varValue; + } catch (error) { + console.warn("Failed to parse SQLCMD variables from XML:", error); } + return sqlCmdVariables; } diff --git a/yarn.lock b/yarn.lock index a4222c4f3f..723ce24be4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2374,6 +2374,11 @@ resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.98.0.tgz#5b6fa5bd99ba15313567d3847fb1177832fee08c" integrity sha512-+KuiWhpbKBaG2egF+51KjbGWatTH5BbmWQjSLMDCssb4xF8FJnW4nGH4nuAdOOfMbpD0QlHtI+C3tPq+DoKElg== +"@types/xmldom@^0.1.34": + version "0.1.34" + resolved "https://registry.yarnpkg.com/@types/xmldom/-/xmldom-0.1.34.tgz#a752f73bdf09cc6d78b3d3b2e7ca4dd04cc96fd2" + integrity sha512-7eZFfxI9XHYjJJuugddV6N5YNeXgQE1lArWOcd1eCOKWb/FGs5SIjacSYuEJuwhsGS3gy4RuZ5EUIcqYscuPDA== + "@typescript-eslint/eslint-plugin@8.34.0": version "8.34.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.0.tgz#96c9f818782fe24cd5883a5d517ca1826d3fa9c2" @@ -2701,6 +2706,11 @@ optionalDependencies: keytar "^7.7.0" +"@xmldom/xmldom@^0.8.11": + version "0.8.11" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz#b79de2d67389734c57c52595f7a7305e30c2d608" + integrity sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw== + "@xyflow/react@^12.4.4": version "12.4.4" resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.4.4.tgz#3f1042fba8b3d6cb64cc76e3e1d1b7dd69a34448" @@ -8617,14 +8627,6 @@ xml2js@^0.5.0: sax ">=0.6.0" xmlbuilder "~11.0.0" -xml2js@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499" - integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== - dependencies: - sax ">=0.6.0" - xmlbuilder "~11.0.0" - xml@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" From 91a3920f0fa3e0e08948c2ff9b668f22a9a104b5 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 21 Oct 2025 17:08:29 -0500 Subject: [PATCH 74/94] typo updates and guard check added --- localization/l10n/bundle.l10n.json | 1 + localization/xliff/vscode-mssql.xlf | 3 +++ src/constants/locConstants.ts | 1 + src/publishProject/projectUtils.ts | 2 +- src/publishProject/publishProjectWebViewController.ts | 7 ++++++- 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index db39ebb644..7b652fedaf 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1467,6 +1467,7 @@ "Failed to load publish profile": "Failed to load publish profile", "Publish profile saved to: {0}": "Publish profile saved to: {0}", "Failed to save publish profile": "Failed to save publish profile", + "DacFx service is not available": "DacFx service is not available", "Schema Compare": "Schema Compare", "Options have changed. Recompare to see the comparison?": "Options have changed. Recompare to see the comparison?", "Failed to generate script: '{0}'/{0} is the error message returned from the generate script operation": { diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 527b0177aa..6c455391a3 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -944,6 +944,9 @@ Custom Zoom + + DacFx service is not available + Data Type diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index c2f1a26786..fd1f2fd42a 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1347,6 +1347,7 @@ export class PublishProject { return l10n.t("Publish profile saved to: {0}", path); }; public static PublishProfileSaveFailed = l10n.t("Failed to save publish profile"); + public static DacFxServiceNotAvailable = l10n.t("DacFx service is not available"); } export class SchemaCompare { diff --git a/src/publishProject/projectUtils.ts b/src/publishProject/projectUtils.ts index 5f794a2a3e..79fc4c4485 100644 --- a/src/publishProject/projectUtils.ts +++ b/src/publishProject/projectUtils.ts @@ -214,7 +214,7 @@ export function extractServerFromConnectionString(connectionString: string): str } // Match "Data Source=serverName" or "Server=serverName" (case-insensitive) - // TODO: currently returning the whole connection string, need to revist with server|database connection task + // TODO: currently returning the whole connection string, need to revisit with server|database connection task const match = connectionString.match(/(?:Data Source|Server)=([^;]+)/i); return match ? match[1].trim() : ""; } diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index d314d63916..f1369d0a76 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -274,6 +274,11 @@ export class PublishProjectWebViewController extends FormWebviewController< } // Save the profile using DacFx service + if (!this._dacFxService) { + void vscode.window.showErrorMessage(Loc.DacFxServiceNotAvailable); + return state; + } + try { const databaseName = state.formState.databaseName || projectName; // TODO: Build connection string from connection details when server/database selection is implemented @@ -282,7 +287,7 @@ export class PublishProjectWebViewController extends FormWebviewController< Object.entries(state.formState.sqlCmdVariables || {}), ); - await this._dacFxService!.savePublishProfile( + await this._dacFxService.savePublishProfile( fileUri.fsPath, databaseName, connectionString, From a79bc6b8e1c02bb36e7d91b9115cf4d335f8a985 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 21 Oct 2025 18:25:13 -0500 Subject: [PATCH 75/94] connection null check --- src/publishProject/publishProjectWebViewController.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 6079b9f7d7..10b57c443e 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -356,10 +356,13 @@ export class PublishProjectWebViewController extends FormWebviewController< // IMPORTANT: Get the connection profile FIRST, before any async calls // The connection URI may be removed from activeConnections during async operations const connection = this._connectionManager.activeConnections[connectionUri]; - const connectionProfile = connection?.credentials as IConnectionProfile; + if (!connection || !connection.credentials) { + return; // Connection not found or no credentials available + } + const connectionProfile = connection.credentials as IConnectionProfile; if (!connectionProfile || !connectionProfile.server) { - return; // Connection no longer available or invalid + return; // Connection profile invalid or no server specified } // Update server name immediately From 0eb797994046b4a6a0f7f9d79f739fab6697ad0b Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 22 Oct 2025 00:47:32 -0500 Subject: [PATCH 76/94] optimizing the validation logic --- .../publishProjectWebViewController.ts | 77 ++++++++++--------- .../components/FormFieldComponents.tsx | 6 +- .../pages/PublishProject/publishProject.tsx | 8 +- src/sharedInterfaces/publishDialog.ts | 3 +- 4 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index f1369d0a76..538bd1c385 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -62,6 +62,7 @@ export class PublishProjectWebViewController extends FormWebviewController< projectFilePath, inProgress: false, lastPublishResult: undefined, + hasFormErrors: true, deploymentOptions: deploymentOptions, } as PublishDialogState, { @@ -166,6 +167,9 @@ export class PublishProjectWebViewController extends FormWebviewController< } void this.updateItemVisibility(); + + // Run initial validation to set hasFormErrors state for button enablement + await this.validateForm(this.state.formState, undefined, true); } /** Registers all reducers in pure (immutable) style */ @@ -358,49 +362,48 @@ export class PublishProjectWebViewController extends FormWebviewController< propertyName?: keyof IPublishForm, updateValidation?: boolean, ): Promise<(keyof IPublishForm)[]> { - // Call parent validation logic + // Call parent validation logic which returns array of fields with errors const erroredInputs = await super.validateForm(formTarget, propertyName, updateValidation); - // Update validation state properties - if (updateValidation) { - this.updateFormValidationState(); - } + // erroredInputs only contains fields validated with updateValidation=true (on blur) + // So we also need to check for missing required values (which may not be validated yet on dialog open) + const hasValidationErrors = updateValidation && erroredInputs.length > 0; + const hasMissingRequiredValues = this.hasAnyMissingRequiredValues(); + + // hasFormErrors state tracks to disable buttons if ANY errors exist + this.state.hasFormErrors = hasValidationErrors || hasMissingRequiredValues; return erroredInputs; } - private updateFormValidationState(): void { - // Check if any visible component has validation errors - this.state.hasValidationErrors = Object.values(this.state.formComponents).some( - (component) => - !component.hidden && - component.validation !== undefined && - component.validation.isValid === false, - ); + /** + * Checks if any required fields are missing values. + * Used to determine if publish/generate script buttons should be disabled. + */ + private hasAnyMissingRequiredValues(): boolean { + return Object.values(this.state.formComponents).some((component) => { + if (component.hidden || !component.required) return false; + + const value = this.state.formState[component.propertyName as keyof IPublishForm]; + return ( + value === undefined || + (typeof value === "string" && value.trim() === "") || + (typeof value === "boolean" && value !== true) + ); + }); + } - // Check if any required fields are missing values - this.state.hasMissingRequiredValues = Object.values(this.state.formComponents).some( - (component) => { - if (component.hidden || !component.required) { - return false; - } - const key = component.propertyName as keyof IPublishForm; - const raw = this.state.formState[key]; - // Missing if undefined/null - if (raw === undefined) { - return true; - } - // For strings, empty/whitespace is missing - if (typeof raw === "string") { - return raw.trim().length === 0; - } - // For booleans (e.g. required checkbox), must be true - if (typeof raw === "boolean") { - return raw !== true; - } - // For numbers, allow 0 (not missing) - return false; - }, - ); + /** + * Called after a form property is set and validated. + * Revalidates all fields when publish target changes to update button state. + * Update visibility first, then validate based on new visibility + */ + public async afterSetFormProperty(propertyName: keyof IPublishForm): Promise { + // When publish target changes, fields get hidden/shown, so revalidate everything + if (propertyName === PublishFormFields.PublishTarget) { + await this.updateItemVisibility(); + await this.validateForm(this.state.formState, undefined, false); + this.updateState(); + } } } diff --git a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx index ecb9bc753a..50a4855d4e 100644 --- a/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx +++ b/src/reactviews/pages/PublishProject/components/FormFieldComponents.tsx @@ -42,7 +42,7 @@ export function renderInput( propertyName: component.propertyName, isAction: false, value: newValue, - updateValidation: false, + updateValidation: true, }); } }; @@ -67,7 +67,7 @@ export function renderInput( validationState={getValidationState(component.validation)} orientation="horizontal"> s.formState); const inProgress = usePublishDialogSelector((s) => s.inProgress); - const hasValidationErrors = usePublishDialogSelector((s) => s.hasValidationErrors); - const hasMissingRequiredValues = usePublishDialogSelector((s) => s.hasMissingRequiredValues); + const hasFormErrors = usePublishDialogSelector((s) => s.hasFormErrors); // Check if component is properly initialized and ready for user interaction const isComponentReady = !!context && !!formState; - // Disabled criteria: disable when not ready, in progress, has validation errors, or missing required fields - const readyToPublish = - !isComponentReady || inProgress || hasValidationErrors || hasMissingRequiredValues; + // Disabled criteria: disable when not ready, in progress, or has form errors + const readyToPublish = !isComponentReady || inProgress || hasFormErrors; if (!isComponentReady) { return
Loading...
; diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 4391440599..d53a0c9351 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -70,8 +70,7 @@ export interface PublishDialogState inProgress: boolean; lastPublishResult?: { success: boolean; details?: string }; projectProperties?: mssql.GetProjectPropertiesResult & { targetVersion?: string }; - hasValidationErrors?: boolean; - hasMissingRequiredValues?: boolean; + hasFormErrors?: boolean; deploymentOptions?: mssql.DeploymentOptions; } From 962dde42593da1ce529e9ff33fbcf38f7221b53c Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 22 Oct 2025 10:31:01 -0500 Subject: [PATCH 77/94] fixing the duplicate afterSetFormProperty methods --- .../publishProjectWebViewController.ts | 86 +++++++++---------- 1 file changed, 39 insertions(+), 47 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index f24727f210..ca4d0a0de1 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -397,6 +397,9 @@ export class PublishProjectWebViewController extends FormWebviewController< this.state.formState.databaseName = databases[0]; } + // Validate form to update button state after connection + await this.validateForm(this.state.formState, undefined, false); + // Update UI immediately to reflect the new connection this.updateState(); } catch (err) { @@ -428,52 +431,55 @@ export class PublishProjectWebViewController extends FormWebviewController< } /** - * Override to handle publish target changes and manage database dropdown options + * Called after a form property is set and validated. + * Handles publish target changes for both validation and database dropdown management. */ public async afterSetFormProperty(propertyName: keyof IPublishForm): Promise { if (propertyName === PublishFormFields.PublishTarget) { const databaseComponent = this.state.formComponents[PublishFormFields.DatabaseName]; - if (!databaseComponent) { - return; - } - - // When switching TO LOCAL_CONTAINER - if (this.state.formState.publishTarget === PublishTarget.LocalContainer) { - // Store current database list and selected value to restore later - if (databaseComponent.options && databaseComponent.options.length > 0) { - this.state.previousDatabaseList = [...databaseComponent.options]; - this.state.previousSelectedDatabase = this.state.formState.databaseName; - } - // Clear database dropdown options for container (freeform only) - databaseComponent.options = []; + if (databaseComponent) { + // When switching TO LOCAL_CONTAINER + if (this.state.formState.publishTarget === PublishTarget.LocalContainer) { + // Store current database list and selected value to restore later + if (databaseComponent.options && databaseComponent.options.length > 0) { + this.state.previousDatabaseList = [...databaseComponent.options]; + this.state.previousSelectedDatabase = this.state.formState.databaseName; + } + // Clear database dropdown options for container (freeform only) + databaseComponent.options = []; - // Reset to project name for container mode - this.state.formState.databaseName = path.basename( - this.state.projectFilePath, - path.extname(this.state.projectFilePath), - ); + // Reset to project name for container mode + this.state.formState.databaseName = path.basename( + this.state.projectFilePath, + path.extname(this.state.projectFilePath), + ); - // Clear connection string when switching to container target - this.state.connectionString = undefined; - } - // When switching TO EXISTING_SERVER - else if (this.state.formState.publishTarget === PublishTarget.ExistingServer) { - // Restore previous database list if it was stored (preserve the list from when user connected) - if (this.state.previousDatabaseList && this.state.previousDatabaseList.length > 0) { - databaseComponent.options = [...this.state.previousDatabaseList]; - - // Restore previously selected database - if (this.state.previousSelectedDatabase) { - this.state.formState.databaseName = this.state.previousSelectedDatabase; + // Clear connection string when switching to container target + this.state.connectionString = undefined; + } + // When switching TO EXISTING_SERVER + else if (this.state.formState.publishTarget === PublishTarget.ExistingServer) { + // Restore previous database list if it was stored (preserve the list from when user connected) + if ( + this.state.previousDatabaseList && + this.state.previousDatabaseList.length > 0 + ) { + databaseComponent.options = [...this.state.previousDatabaseList]; + + // Restore previously selected database + if (this.state.previousSelectedDatabase) { + this.state.formState.databaseName = this.state.previousSelectedDatabase; + } } } } + // Update visibility and validate for button enablement (without showing validation messages) + await this.updateItemVisibility(); + await this.validateForm(this.state.formState, undefined, false); this.updateState(); } - - return Promise.resolve(); } public updateItemVisibility(state?: PublishDialogState): Promise { @@ -534,18 +540,4 @@ export class PublishProjectWebViewController extends FormWebviewController< ); }); } - - /** - * Called after a form property is set and validated. - * Revalidates all fields when publish target changes to update button state. - * Update visibility first, then validate based on new visibility - */ - public async afterSetFormProperty(propertyName: keyof IPublishForm): Promise { - // When publish target changes, fields get hidden/shown, so revalidate everything - if (propertyName === PublishFormFields.PublishTarget) { - await this.updateItemVisibility(); - await this.validateForm(this.state.formState, undefined, false); - this.updateState(); - } - } } From 2ae2d40ea677bb80d1585a9400e362208718c792 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 22 Oct 2025 14:50:01 -0500 Subject: [PATCH 78/94] ignore option group added --- src/constants/locConstants.ts | 1 + .../publishProjectWebViewController.ts | 23 ++++++++++- .../advancedDeploymentOptionsDrawer.tsx | 38 +++++++++---------- typings/vscode-mssql.d.ts | 3 ++ 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index c63db710aa..3460a2863b 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1327,6 +1327,7 @@ export class PublishProject { public static AdvancedOptions = l10n.t("Advanced..."); public static AdvancedPublishSettings = l10n.t("Advanced Deployment Options"); public static GeneralOptions = l10n.t("General Options"); + public static IgnoreOptions = l10n.t("Ignore Options"); public static ExcludeObjectTypes = l10n.t("Exclude Object Types"); public static SqlServerPortNumber = l10n.t("SQL Server port number"); public static SqlServerAdminPassword = l10n.t("SQL Server admin password"); diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 9931ee43cd..bc120ea201 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -612,9 +612,9 @@ export class PublishProjectWebViewController extends FormWebviewController< entries: { key: string; displayName: string; description: string; value: boolean }[]; }[] = []; - // General Options group + // Process boolean options and split into General and Ignore groups if (this.state.deploymentOptions.booleanOptionsDictionary) { - const generalEntries = Object.entries( + const allBooleanEntries = Object.entries( this.state.deploymentOptions.booleanOptionsDictionary, ).map(([key, option]) => ({ key, @@ -623,6 +623,16 @@ export class PublishProjectWebViewController extends FormWebviewController< value: option.value, })); + // Split entries into General and Ignore based on displayName starting with "Ignore" + const generalEntries = allBooleanEntries + .filter((entry) => !entry.displayName.startsWith("Ignore")) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + + const ignoreEntries = allBooleanEntries + .filter((entry) => entry.displayName.startsWith("Ignore")) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + + // Add General Options group if (generalEntries.length > 0) { groups.push({ key: "General", @@ -630,6 +640,15 @@ export class PublishProjectWebViewController extends FormWebviewController< entries: generalEntries, }); } + + // Add Ignore Options group + if (ignoreEntries.length > 0) { + groups.push({ + key: "Ignore", + label: Loc.IgnoreOptions, + entries: ignoreEntries, + }); + } } // Exclude Object Types group diff --git a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx index f71dbf39bf..7871130359 100644 --- a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx +++ b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx @@ -79,11 +79,7 @@ export const AdvancedDeploymentOptionsDrawer = ({ if (!searchText) return true; const lowerSearch = searchText.toLowerCase(); - return ( - option.displayName.toLowerCase().includes(lowerSearch) || - option.key.toLowerCase().includes(lowerSearch) || - option.description.toLowerCase().includes(lowerSearch) - ); + return option.displayName.toLowerCase().includes(lowerSearch); }; // Render a single option (same for all groups) @@ -157,21 +153,23 @@ export const AdvancedDeploymentOptionsDrawer = ({ } }} openItems={searchText ? optionGroups.map((g) => g.key) : userOpenedSections}> - {optionGroups.map((group) => ( - - {group.label} - -
- {group.entries - .filter((option) => isOptionVisible(option)) - .map((option) => renderOption(option))} -
-
-
- ))} + {optionGroups + .filter((group) => group.entries.some((option) => isOptionVisible(option))) + .map((group) => ( + + {group.label} + +
+ {group.entries + .filter((option) => isOptionVisible(option)) + .map((option) => renderOption(option))} +
+
+
+ ))} diff --git a/typings/vscode-mssql.d.ts b/typings/vscode-mssql.d.ts index eb6f40595f..71f4eb6b03 100644 --- a/typings/vscode-mssql.d.ts +++ b/typings/vscode-mssql.d.ts @@ -530,6 +530,7 @@ declare module "vscode-mssql" { taskExecutionMode: TaskExecutionMode, ): Thenable; getOptionsFromProfile(profilePath: string): Thenable; + getDefaultPublishOptions(): Thenable; validateStreamingJob( packageFilePath: string, createStreamingJobTsql: string, @@ -1308,6 +1309,8 @@ declare module "vscode-mssql" { profilePath: string; } + export interface GetDefaultPublishOptionsParams {} + export interface ValidateStreamingJobParams { packageFilePath: string; createStreamingJobTsql: string; From d97d35289a78ae4b0072b8be374f7d535bb6f73b Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 22 Oct 2025 15:21:25 -0500 Subject: [PATCH 79/94] LOC --- localization/l10n/bundle.l10n.json | 1 + localization/xliff/vscode-mssql.xlf | 3 +++ 2 files changed, 4 insertions(+) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 8b375cbb2a..cbd0e37f70 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1457,6 +1457,7 @@ "Local development container": "Local development container", "New SQL Server local development container": "New SQL Server local development container", "New Azure SQL logical server (Preview)": "New Azure SQL logical server (Preview)", + "Ignore Options": "Ignore Options", "SQL Server port number": "SQL Server port number", "SQL Server admin password": "SQL Server admin password", "Confirm SQL Server admin password": "Confirm SQL Server admin password", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index c92c71d8c3..e0ed4fef64 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -1719,6 +1719,9 @@ I'm sorry, I can only assist with SQL-related questions. + + Ignore Options + Ignore Tenant From 5e4e95001692f1c0deb5bb405fc2a884fda81759 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Mon, 27 Oct 2025 12:25:31 -0500 Subject: [PATCH 80/94] moving method to Utils --- .../publishProjectWebViewController.ts | 25 +++++------------- src/utils/utils.ts | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 538bd1c385..6c47b7da83 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -26,6 +26,7 @@ import { SqlProjectsService } from "../services/sqlProjectsService"; import { Deferred } from "../protocol"; import { TelemetryViews, TelemetryActions } from "../sharedInterfaces/telemetry"; import { getSqlServerContainerTagsForTargetVersion } from "../deployment/dockerUtils"; +import { hasAnyMissingRequiredValues } from "../utils/utils"; export class PublishProjectWebViewController extends FormWebviewController< IPublishForm, @@ -202,7 +203,7 @@ export class PublishProjectWebViewController extends FormWebviewController< }, }); - if (fileUris && fileUris.length > 0) { + if (fileUris?.length > 0) { const selectedPath = fileUris[0].fsPath; try { @@ -368,7 +369,10 @@ export class PublishProjectWebViewController extends FormWebviewController< // erroredInputs only contains fields validated with updateValidation=true (on blur) // So we also need to check for missing required values (which may not be validated yet on dialog open) const hasValidationErrors = updateValidation && erroredInputs.length > 0; - const hasMissingRequiredValues = this.hasAnyMissingRequiredValues(); + const hasMissingRequiredValues = hasAnyMissingRequiredValues( + this.state.formComponents, + this.state.formState, + ); // hasFormErrors state tracks to disable buttons if ANY errors exist this.state.hasFormErrors = hasValidationErrors || hasMissingRequiredValues; @@ -376,23 +380,6 @@ export class PublishProjectWebViewController extends FormWebviewController< return erroredInputs; } - /** - * Checks if any required fields are missing values. - * Used to determine if publish/generate script buttons should be disabled. - */ - private hasAnyMissingRequiredValues(): boolean { - return Object.values(this.state.formComponents).some((component) => { - if (component.hidden || !component.required) return false; - - const value = this.state.formState[component.propertyName as keyof IPublishForm]; - return ( - value === undefined || - (typeof value === "string" && value.trim() === "") || - (typeof value === "boolean" && value !== true) - ); - }); - } - /** * Called after a form property is set and validated. * Revalidates all fields when publish target changes to update button state. diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 1f2208755f..1b962f1fbb 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -8,6 +8,7 @@ import * as vscode from "vscode"; import type { PagedAsyncIterableIterator } from "@azure/core-paging"; import { IConnectionInfo } from "vscode-mssql"; import * as os from "os"; +import { FormItemSpec, FormState } from "../sharedInterfaces/form"; export async function exists(path: string, uri?: vscode.Uri): Promise { if (uri) { @@ -141,3 +142,28 @@ export function parseEnum>( return undefined; } + +/** + * Checks if any required fields are missing values in a form. + * Used to determine if form submission buttons should be disabled. + * + * @param formComponents - The form components to check + * @param formState - The current form state with values + * @returns true if any required fields are missing values, false otherwise + */ +export function hasAnyMissingRequiredValues< + TForm, + TState extends FormState, + TFormItemSpec extends FormItemSpec, +>(formComponents: Partial>, formState: TForm): boolean { + return Object.values(formComponents).some((component: TFormItemSpec | undefined) => { + if (!component || component.hidden || !component.required) return false; + + const value = formState[component.propertyName as keyof TForm]; + return ( + value === undefined || + (typeof value === "string" && value.trim() === "") || + (typeof value === "boolean" && value !== true) + ); + }); +} From 87b3190063f5f5dd9a50b9354bbb951bd3826d21 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Mon, 27 Oct 2025 15:10:13 -0500 Subject: [PATCH 81/94] logging when database connection fails --- localization/l10n/bundle.l10n.json | 1 + localization/xliff/vscode-mssql.xlf | 3 ++ src/constants/locConstants.ts | 1 + .../publishProjectWebViewController.ts | 40 +++++++++++++------ .../components/ConnectionSection.tsx | 4 +- 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index ca43fb0591..8cddc4e49b 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1476,6 +1476,7 @@ "Publish profile saved to: {0}": "Publish profile saved to: {0}", "Failed to save publish profile": "Failed to save publish profile", "DacFx service is not available": "DacFx service is not available", + "Failed to list databases": "Failed to list databases", "Schema Compare": "Schema Compare", "Options have changed. Recompare to see the comparison?": "Options have changed. Recompare to see the comparison?", "Failed to generate script: '{0}'/{0} is the error message returned from the generate script operation": { diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 7a47e0e03c..5fbd623e28 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -1450,6 +1450,9 @@ {0} is the tenant id {1} is the account name + + Failed to list databases + Failed to load publish profile diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index 101d66db6c..21f1ae93bf 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1354,6 +1354,7 @@ export class PublishProject { }; public static PublishProfileSaveFailed = l10n.t("Failed to save publish profile"); public static DacFxServiceNotAvailable = l10n.t("DacFx service is not available"); + public static FailedToListDatabases = l10n.t("Failed to list databases"); } export class SchemaCompare { diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 63a329acfb..096faa3af3 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -28,7 +28,7 @@ import { SqlProjectsService } from "../services/sqlProjectsService"; import { Deferred } from "../protocol"; import { TelemetryViews, TelemetryActions } from "../sharedInterfaces/telemetry"; import { getSqlServerContainerTagsForTargetVersion } from "../deployment/dockerUtils"; -import { hasAnyMissingRequiredValues } from "../utils/utils"; +import { hasAnyMissingRequiredValues, getErrorMessage } from "../utils/utils"; export class PublishProjectWebViewController extends FormWebviewController< IPublishForm, @@ -382,20 +382,34 @@ export class PublishProjectWebViewController extends FormWebviewController< this.state.connectionString = connectionString; // Get databases - const databases = await this._connectionManager.listDatabases(connectionUri); + try { + const databases = await this._connectionManager.listDatabases(connectionUri); + + // Update database dropdown options + const databaseComponent = this.state.formComponents[PublishFormFields.DatabaseName]; + if (databaseComponent) { + databaseComponent.options = databases.map((db) => ({ + displayName: db, + value: db, + })); + } - // Update database dropdown options - const databaseComponent = this.state.formComponents[PublishFormFields.DatabaseName]; - if (databaseComponent) { - databaseComponent.options = databases.map((db) => ({ - displayName: db, - value: db, - })); - } + // Optionally select the first database if available + if (databases.length > 0 && !this.state.formState.databaseName) { + this.state.formState.databaseName = databases[0]; + } + } catch (dbError) { + // Show error message to user when database listing fails + void vscode.window.showErrorMessage( + `${Loc.FailedToListDatabases}: ${getErrorMessage(dbError)}`, + ); - // Optionally select the first database if available - if (databases.length > 0 && !this.state.formState.databaseName) { - this.state.formState.databaseName = databases[0]; + // Log the error for diagnostics + sendActionEvent( + TelemetryViews.SqlProjects, + TelemetryActions.PublishProjectConnectionError, + { error: dbError instanceof Error ? dbError.message : String(dbError) }, + ); } // Validate form to update button state after connection diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx index 524a2984b3..310f06d2bb 100644 --- a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -24,7 +24,7 @@ const useStyles = makeStyles({ export const ConnectionSection: React.FC = () => { const publishCtx = useContext(PublishProjectContext); const formStyles = useFormStyles(); - const classes = useStyles(); + const styles = useStyles(); const serverComponent = usePublishDialogSelector((s) => s.formComponents.serverName); const databaseComponent = usePublishDialogSelector((s) => s.formComponents.databaseName); const serverValue = usePublishDialogSelector((s) => s.formState.serverName); @@ -53,7 +53,7 @@ export const ConnectionSection: React.FC = () => { return (
-
+
{renderInput(serverComponent, localServer, publishCtx, { readOnly: true, contentAfter: ( From 2e0af226247750e6396ad0d2e64d14fa25ad64ea Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Mon, 27 Oct 2025 16:10:19 -0500 Subject: [PATCH 82/94] handle new connection details updates --- .../publishProjectWebViewController.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 096faa3af3..869dbb2a76 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -9,7 +9,7 @@ import * as mssql from "vscode-mssql"; import * as constants from "../constants/constants"; import { FormWebviewController } from "../forms/formWebviewController"; import VscodeWrapper from "../controllers/vscodeWrapper"; -import ConnectionManager from "../controllers/connectionManager"; +import ConnectionManager, { ConnectionSuccessfulEvent } from "../controllers/connectionManager"; import { IConnectionProfile } from "../models/interfaces"; import { PublishProject as Loc } from "../constants/locConstants"; import { @@ -110,8 +110,8 @@ export class PublishProjectWebViewController extends FormWebviewController< this._connectionManager.onSuccessfulConnection(async (event) => { // Only auto-populate if waiting for a new connection if (this.state.waitingForNewConnection) { - // Auto-populate from the new connection (this will call updateState internally) - await this.autoSelectNewConnection(event.fileUri); + // Auto-populate form fields from the successful connection event + await this.handleSuccessfulConnection(event); } }), ); @@ -355,12 +355,13 @@ export class PublishProjectWebViewController extends FormWebviewController< ); } - /** Auto-select a new connection and populate server/database fields */ - private async autoSelectNewConnection(connectionUri: string): Promise { + /** + * Handle successful connection event and populate form fields with connection details, such as server name and database list. + * @param event The connection successful event containing connection details + */ + private async handleSuccessfulConnection(event: ConnectionSuccessfulEvent): Promise { try { - // IMPORTANT: Get the connection profile FIRST, before any async calls - // The connection URI may be removed from activeConnections during async operations - const connection = this._connectionManager.activeConnections[connectionUri]; + const connection = event.connection; if (!connection || !connection.credentials) { return; // Connection not found or no credentials available } @@ -373,17 +374,16 @@ export class PublishProjectWebViewController extends FormWebviewController< // Update server name immediately this.state.formState.serverName = connectionProfile.server; - // Get connection string first - const connectionString = await this._connectionManager.getConnectionString( - connectionUri, + // Get connection string using the fileUri from the event + this.state.connectionString = await this._connectionManager.getConnectionString( + event.fileUri, true, // includePassword true, // includeApplicationName ); - this.state.connectionString = connectionString; // Get databases try { - const databases = await this._connectionManager.listDatabases(connectionUri); + const databases = await this._connectionManager.listDatabases(event.fileUri); // Update database dropdown options const databaseComponent = this.state.formComponents[PublishFormFields.DatabaseName]; From 809ddaf1867c0f3034b65f7cf9519edfd0cf12b8 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Mon, 27 Oct 2025 16:52:37 -0500 Subject: [PATCH 83/94] test fixes --- test/unit/publishProjectWebViewController.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index bddf4f7580..3e36a20077 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -41,6 +41,7 @@ suite("PublishProjectWebViewController Tests", () => { mockDacFxService = {}; mockConnectionManager = { onConnectionsChanged: sinon.stub(), + onSuccessfulConnection: sinon.stub().returns({ dispose: sinon.stub() }), activeConnections: {}, listDatabases: sinon.stub().resolves([]), getConnectionString: sinon.stub().returns(""), From 01c1dbc56b1fd2128a960eb71c7749207c39cb10 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 28 Oct 2025 01:05:18 -0500 Subject: [PATCH 84/94] all are done except update profile and save profile changes with options and few tests --- src/constants/locConstants.ts | 4 +- .../publishProjectWebViewController.ts | 49 +--- src/reactviews/common/locConstants.ts | 4 +- .../advancedDeploymentOptionsDrawer.tsx | 230 ++++++++++++------ .../publishProjectStateProvider.tsx | 5 +- src/sharedInterfaces/publishDialog.ts | 7 +- .../publishProjectWebViewController.test.ts | 126 ++++++++++ typings/vscode-mssql.d.ts | 3 - 8 files changed, 304 insertions(+), 124 deletions(-) diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index 8563aafe6b..d0b6eabf52 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1329,8 +1329,8 @@ export class PublishProject { public static PublishTargetNewAzureServer = l10n.t("New Azure SQL logical server (Preview)"); public static GenerateScript = l10n.t("Generate Script"); public static Publish = l10n.t("Publish"); - public static AdvancedOptions = l10n.t("Advanced..."); - public static AdvancedPublishSettings = l10n.t("Advanced Deployment Options"); + public static AdvancedOptions = l10n.t("Advanced"); + public static AdvancedPublishSettings = l10n.t("Advanced Publish Options"); public static GeneralOptions = l10n.t("General Options"); public static IgnoreOptions = l10n.t("Ignore Options"); public static ExcludeObjectTypes = l10n.t("Exclude Object Types"); diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 8947f1b4de..365a420f5c 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -69,6 +69,9 @@ export class PublishProjectWebViewController extends FormWebviewController< lastPublishResult: undefined, hasFormErrors: true, deploymentOptions: deploymentOptions, + defaultDeploymentOptions: deploymentOptions + ? structuredClone(deploymentOptions) + : undefined, waitingForNewConnection: false, } as PublishDialogState, { @@ -220,47 +223,15 @@ export class PublishProjectWebViewController extends FormWebviewController< }); this.registerReducer( - "updateDeploymentOption", - async (state: PublishDialogState, payload: { optionName: string; value: boolean }) => { - // Update a specific deployment option value - if (!state.deploymentOptions) { - return state; - } - - const updatedOptions = { ...state.deploymentOptions }; - - // Check if this is a boolean option - if (updatedOptions.booleanOptionsDictionary?.[payload.optionName]) { - updatedOptions.booleanOptionsDictionary[payload.optionName] = { - ...updatedOptions.booleanOptionsDictionary[payload.optionName], - value: payload.value, - }; - } - // Check if this is an exclude object type - else if (updatedOptions.objectTypesDictionary?.[payload.optionName]) { - // excludeObjectTypes.value is a string array of excluded types - const currentExcluded = updatedOptions.excludeObjectTypes?.value || []; - let newExcluded: string[]; - - if (payload.value) { - // Add to excluded list if not already there - newExcluded = currentExcluded.includes(payload.optionName) - ? currentExcluded - : [...currentExcluded, payload.optionName]; - } else { - // Remove from excluded list - newExcluded = currentExcluded.filter((type) => type !== payload.optionName); - } - - updatedOptions.excludeObjectTypes = { - ...updatedOptions.excludeObjectTypes, - value: newExcluded, - }; - } - + "updateDeploymentOptions", + async ( + state: PublishDialogState, + payload: { deploymentOptions: mssql.DeploymentOptions }, + ) => { + // Simply replace entire deploymentOptions - much simpler! const newState = { ...state, - deploymentOptions: updatedOptions, + deploymentOptions: payload.deploymentOptions, }; // Regenerate grouped options after update diff --git a/src/reactviews/common/locConstants.ts b/src/reactviews/common/locConstants.ts index 6e76b1e811..538c3facc0 100644 --- a/src/reactviews/common/locConstants.ts +++ b/src/reactviews/common/locConstants.ts @@ -909,8 +909,8 @@ export class LocConstants { SaveAs: l10n.t("Save As..."), generateScript: l10n.t("Generate Script"), publish: l10n.t("Publish"), - advancedOptions: l10n.t("Advanced..."), - advancedPublishSettings: l10n.t("Advanced Deployment Options"), + advancedOptions: l10n.t("Advanced"), + advancedPublishSettings: l10n.t("Advanced Publish Options"), generalOptions: l10n.t("General Options"), excludeObjectTypes: l10n.t("Exclude Object Types"), }; diff --git a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx index 7871130359..ab1467f383 100644 --- a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx +++ b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx @@ -15,12 +15,11 @@ import { OverlayDrawer, SearchBox, Checkbox, - Tooltip, + InfoLabel, makeStyles, - shorthands, } from "@fluentui/react-components"; -import { Dismiss24Regular, InfoRegular } from "@fluentui/react-icons"; -import { useContext, useState } from "react"; +import { Dismiss24Regular } from "@fluentui/react-icons"; +import React, { useState, useContext } from "react"; import { LocConstants } from "../../../common/locConstants"; import { PublishProjectContext } from "../publishProjectStateProvider"; import { usePublishDialogSelector } from "../publishDialogSelector"; @@ -30,21 +29,28 @@ const useStyles = makeStyles({ optionsList: { display: "flex", flexDirection: "column", - gap: "4px", + gap: "8px", }, - optionItem: { + drawerContent: { display: "flex", - alignItems: "center", - gap: "8px", - ...shorthands.padding("4px", "0px"), + flexDirection: "column", + height: "100%", }, - optionLabel: { + scrollableContent: { flex: 1, - cursor: "pointer", + overflow: "auto", + paddingBottom: "16px", }, - infoIcon: { - color: "var(--colorNeutralForeground3)", - cursor: "help", + stickyFooter: { + position: "sticky", + bottom: "0", + backgroundColor: "var(--colorNeutralBackground1)", + borderTop: "1px solid var(--colorNeutralStroke2)", + padding: "16px 0", + display: "flex", + justifyContent: "flex-end", + gap: "8px", + marginTop: "auto", }, }); @@ -62,11 +68,78 @@ export const AdvancedDeploymentOptionsDrawer = ({ const [userOpenedSections, setUserOpenedSections] = useState(["General"]); const loc = LocConstants.getInstance(); - // Get grouped options from state (prepared by controller) - const optionGroups = usePublishDialogSelector((s) => s.groupedAdvancedOptions ?? []); + // Local state for temporary changes (only committed on OK), clone the entire deploymentOptions + const state = usePublishDialogSelector((s) => s); + const [localChanges, setLocalChanges] = useState(() => + state.deploymentOptions ? structuredClone(state.deploymentOptions) : undefined, + ); + + // Update localChanges when drawer opens with fresh data + React.useEffect(() => { + if (isAdvancedDrawerOpen && state.deploymentOptions) { + setLocalChanges(structuredClone(state.deploymentOptions)); + } + }, [isAdvancedDrawerOpen, state.deploymentOptions]); + + // Get grouped options and update values from localChanges + const optionGroups = usePublishDialogSelector((s) => { + if (!s.groupedAdvancedOptions || !localChanges) return []; + + return s.groupedAdvancedOptions.map((group) => ({ + ...group, + entries: group.entries.map((option) => ({ + ...option, + value: + localChanges.booleanOptionsDictionary?.[option.key]?.value ?? + (group.key === "Exclude" + ? (localChanges.excludeObjectTypes?.value?.includes(option.key) ?? false) + : option.value), + })), + })); + }); const handleOptionChange = (optionName: string, checked: boolean) => { - context?.updateDeploymentOption(optionName, checked); + setLocalChanges((prev) => { + if (!prev) return prev; + const updated = structuredClone(prev); + + if (updated.booleanOptionsDictionary?.[optionName]) { + updated.booleanOptionsDictionary[optionName].value = checked; + } else if (updated.objectTypesDictionary?.[optionName]) { + // For exclude object types, checked = excluded + const excludedTypes = updated.excludeObjectTypes!.value; + if (checked && !excludedTypes.includes(optionName)) { + excludedTypes.push(optionName); + } else if (!checked && excludedTypes.includes(optionName)) { + excludedTypes.splice(excludedTypes.indexOf(optionName), 1); + } + } + + return updated; + }); + }; + + const handleReset = () => { + // Reset to default options but keep dialog open + if (state.defaultDeploymentOptions) { + setLocalChanges(structuredClone(state.defaultDeploymentOptions)); + } + }; + + const handleOk = () => { + // Just pass localChanges directly - it's already the complete deploymentOptions! + if (localChanges) { + context?.updateDeploymentOptions(localChanges); + } + setIsAdvancedDrawerOpen(false); + }; + + const handleCancel = () => { + // Reset to original deploymentOptions and close drawer + setLocalChanges( + state.deploymentOptions ? structuredClone(state.deploymentOptions) : undefined, + ); + setIsAdvancedDrawerOpen(false); }; // Helper to check if option matches search @@ -82,7 +155,7 @@ export const AdvancedDeploymentOptionsDrawer = ({ return option.displayName.toLowerCase().includes(lowerSearch); }; - // Render a single option (same for all groups) + // Render a single option - much simpler approach const renderOption = (option: { key: string; displayName: string; @@ -90,24 +163,18 @@ export const AdvancedDeploymentOptionsDrawer = ({ value: boolean; }) => { return ( -
- handleOptionChange(option.key, data.checked === true)} - label={ - handleOptionChange(option.key, !option.value)}> - {option.displayName} - - } - /> - {option.description && ( - - - - )} -
+ handleOptionChange(option.key, data.checked === true)} + label={ + option.description ? ( + {option.displayName} + ) : ( + option.displayName + ) + } + /> ); }; @@ -120,7 +187,7 @@ export const AdvancedDeploymentOptionsDrawer = ({ position="end" size="medium" open={isAdvancedDrawerOpen} - onOpenChange={(_, { open }) => setIsAdvancedDrawerOpen(open)}> + onOpenChange={(_, { open }) => !open && handleCancel()}> } - onClick={() => setIsAdvancedDrawerOpen(false)} + onClick={handleCancel} /> }> {loc.publishProject.advancedPublishSettings} @@ -136,41 +203,58 @@ export const AdvancedDeploymentOptionsDrawer = ({ - setSearchText(data.value ?? "")} - value={searchText} - /> - - { - if (!searchText) { - setUserOpenedSections(data.openItems as string[]); - } - }} - openItems={searchText ? optionGroups.map((g) => g.key) : userOpenedSections}> - {optionGroups - .filter((group) => group.entries.some((option) => isOptionVisible(option))) - .map((group) => ( - - {group.label} - -
- {group.entries - .filter((option) => isOptionVisible(option)) - .map((option) => renderOption(option))} -
-
-
- ))} -
+
+
+ setSearchText(data.value ?? "")} + value={searchText} + /> + + { + if (!searchText) { + setUserOpenedSections(data.openItems as string[]); + } + }} + openItems={ + searchText ? optionGroups.map((g) => g.key) : userOpenedSections + }> + {optionGroups + .filter((group) => + group.entries.some((option) => isOptionVisible(option)), + ) + .map((group) => ( + + {group.label} + +
+ {group.entries + .filter((option) => isOptionVisible(option)) + .map((option) => renderOption(option))} +
+
+
+ ))} +
+
+ +
+ + +
+
); diff --git a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx index df69d2ace0..3a87995b2d 100644 --- a/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx +++ b/src/reactviews/pages/PublishProject/publishProjectStateProvider.tsx @@ -13,6 +13,7 @@ import { PublishProjectProvider, } from "../../../sharedInterfaces/publishDialog"; import { FormEvent } from "../../../sharedInterfaces/form"; +import * as mssql from "vscode-mssql"; export interface PublishProjectContextProps extends PublishProjectProvider { extensionRpc: WebviewRpc; @@ -41,8 +42,8 @@ export const PublishProjectStateProvider: React.FC<{ children: React.ReactNode } savePublishProfile: (publishProfileName: string) => extensionRpc.action("savePublishProfile", { publishProfileName }), openConnectionDialog: () => extensionRpc.action("openConnectionDialog"), - updateDeploymentOption: (optionName: string, value: boolean) => - extensionRpc.action("updateDeploymentOption", { optionName, value }), + updateDeploymentOptions: (deploymentOptions: mssql.DeploymentOptions) => + extensionRpc.action("updateDeploymentOptions", { deploymentOptions }), extensionRpc, }), [extensionRpc], diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index 7804be85d0..cd582b4ce6 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -72,6 +72,7 @@ export interface PublishDialogState projectProperties?: mssql.GetProjectPropertiesResult & { targetVersion?: string }; hasFormErrors?: boolean; deploymentOptions?: mssql.DeploymentOptions; + defaultDeploymentOptions?: mssql.DeploymentOptions; waitingForNewConnection?: boolean; connectionString?: string; previousDatabaseList?: { displayName: string; value: string }[]; @@ -117,7 +118,7 @@ export interface PublishDialogReducers extends FormReducers { selectPublishProfile: {}; savePublishProfile: { publishProfileName: string }; openConnectionDialog: {}; - updateDeploymentOption: { optionName: string; value: boolean }; + updateDeploymentOptions: { deploymentOptions: mssql.DeploymentOptions }; } /** @@ -143,6 +144,6 @@ export interface PublishProjectProvider { savePublishProfile(publishProfileName: string): void; /** Open connection dialog to select server and database */ openConnectionDialog(): void; - /** Update a specific deployment option */ - updateDeploymentOption(optionName: string, value: boolean): void; + /** Update all deployment options at once */ + updateDeploymentOptions(deploymentOptions: mssql.DeploymentOptions): void; } diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 3e36a20077..1e6bf28005 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -531,4 +531,130 @@ suite("PublishProjectWebViewController Tests", () => { expect(controller.state.formState.databaseName).to.equal("SelectedDatabase"); }); //#endregion + + //#region Advanced Options Section Tests + test("advanced options groups contain expected categories", async () => { + const controller = createTestController(); + await controller.initialized.promise; + + const groupedOptions = controller["groupedAdvancedOptions"]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const groupNames = groupedOptions.map((group: any) => group.name); + + // Verify expected group categories exist + expect(groupNames).to.include("General"); + expect(groupNames).to.include("ObjectTypes"); + expect(groupNames).to.include("TableStorage"); + }); + + test("deployment options are properly loaded from publish profile", async () => { + const controller = createTestController(); + await controller.initialized.promise; + + // Mock DacFx service to return specific deployment options + mockDacFxService.getOptionsFromProfile = sandbox.stub().resolves({ + success: true, + deploymentOptions: { + excludeObjectTypes: { + value: ["Users", "Logins"], + description: "Object types to exclude", + displayName: "Exclude Object Types", + }, + booleanOptionsDictionary: { + ignoreTableOptions: { + value: true, + description: "Ignore table options", + displayName: "Ignore Table Options", + }, + allowIncompatiblePlatform: { + value: true, + description: "Allow incompatible platform", + displayName: "Allow Incompatible Platform", + }, + }, + objectTypesDictionary: {}, + }, + }); + + // Mock file system and file picker + const profileXml = ` + + + TestDB + +`; + + const fs = await import("fs"); + sandbox.stub(fs.promises, "readFile").resolves(profileXml); + sandbox + .stub(vscode.window, "showOpenDialog") + .resolves([vscode.Uri.file("test.publish.xml")]); + + const reducerHandlers = controller["_reducerHandlers"] as Map; + const selectPublishProfile = reducerHandlers.get("selectPublishProfile"); + + // Load profile + const newState = await selectPublishProfile(controller.state, {}); + + // Verify deployment options were loaded correctly + expect(newState.deploymentOptions.booleanOptionsDictionary.ignoreTableOptions?.value).to.be + .true; + expect(newState.deploymentOptions.booleanOptionsDictionary.allowIncompatiblePlatform?.value) + .to.be.true; + expect(newState.deploymentOptions.excludeObjectTypes.value).to.deep.equal([ + "Users", + "Logins", + ]); + }); + + test("advanced options are saved correctly in publish profile", async () => { + const controller = createTestController(); + await controller.initialized.promise; + + // Set up deployment options using correct interface structure + controller.state.deploymentOptions = { + excludeObjectTypes: { + value: ["Permissions"], + description: "Object types to exclude", + displayName: "Exclude Object Types", + }, + booleanOptionsDictionary: { + ignoreTableOptions: { + value: true, + description: "Ignore table options", + displayName: "Ignore Table Options", + }, + allowIncompatiblePlatform: { + value: false, + description: "Allow incompatible platform", + displayName: "Allow Incompatible Platform", + }, + }, + objectTypesDictionary: {}, + }; + + controller.state.formState.databaseName = "TestDB"; + controller.state.connectionString = "Server=localhost;Database=TestDB;"; + + // Mock save dialog + sandbox.stub(vscode.window, "showSaveDialog").resolves(vscode.Uri.file("test.publish.xml")); + mockDacFxService.savePublishProfile = sandbox.stub().resolves({ success: true }); + + const reducerHandlers = controller["_reducerHandlers"] as Map; + const savePublishProfile = reducerHandlers.get("savePublishProfile"); + + // Save profile + await savePublishProfile(controller.state, { publishProfileName: "test.publish.xml" }); + + // Verify savePublishProfile was called with deployment options + expect(mockDacFxService.savePublishProfile.calledOnce).to.be.true; + const saveCall = mockDacFxService.savePublishProfile.getCall(0); + const deploymentOptions = saveCall.args[4]; // 5th argument is deployment options + + expect(deploymentOptions.booleanOptionsDictionary.ignoreTableOptions?.value).to.be.true; + expect(deploymentOptions.booleanOptionsDictionary.allowIncompatiblePlatform?.value).to.be + .false; + expect(deploymentOptions.excludeObjectTypes.value).to.deep.equal(["Permissions"]); + }); + //#endregion }); diff --git a/typings/vscode-mssql.d.ts b/typings/vscode-mssql.d.ts index 71f4eb6b03..eb6f40595f 100644 --- a/typings/vscode-mssql.d.ts +++ b/typings/vscode-mssql.d.ts @@ -530,7 +530,6 @@ declare module "vscode-mssql" { taskExecutionMode: TaskExecutionMode, ): Thenable; getOptionsFromProfile(profilePath: string): Thenable; - getDefaultPublishOptions(): Thenable; validateStreamingJob( packageFilePath: string, createStreamingJobTsql: string, @@ -1309,8 +1308,6 @@ declare module "vscode-mssql" { profilePath: string; } - export interface GetDefaultPublishOptionsParams {} - export interface ValidateStreamingJobParams { packageFilePath: string; createStreamingJobTsql: string; From 4adadcb96efdfbc8ca2b8ffb67c766ba4a11d742 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 28 Oct 2025 12:57:07 -0500 Subject: [PATCH 85/94] removed unnecessary advancedgroup options state --- localization/l10n/bundle.l10n.json | 3 +- localization/xliff/vscode-mssql.xlf | 7 +- .../publishProjectWebViewController.ts | 109 +++-------------- .../advancedDeploymentOptionsDrawer.tsx | 111 +++++++++++++++--- src/sharedInterfaces/publishDialog.ts | 14 --- 5 files changed, 112 insertions(+), 132 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 8781d20e57..33bdc49aef 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -645,8 +645,7 @@ "Processing include or exclude all differences operation.": "Processing include or exclude all differences operation.", "Select Profile": "Select Profile", "Save As...": "Save As...", - "Advanced...": "Advanced...", - "Advanced Deployment Options": "Advanced Deployment Options", + "Advanced Publish Options": "Advanced Publish Options", "Exclude Object Types": "Exclude Object Types", "Create New Connection Group": "Create New Connection Group", "Edit Connection Group: {0}/{0} is the name of the connection group being edited": { diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 76043119b7..9d21807b68 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -129,14 +129,11 @@ Advanced Connection Settings - - Advanced Deployment Options - Advanced Options - - Advanced... + + Advanced Publish Options All permissions for extensions to access your connections have been cleared. diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 365a420f5c..d3796687de 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -105,9 +105,6 @@ export class PublishProjectWebViewController extends FormWebviewController< this.state.deploymentOptions.excludeObjectTypes.value = []; } - // Create grouped advanced options for the UI - this.createGroupedAdvancedOptions(); - // Register reducers after initialization this.registerRpcHandlers(); @@ -228,16 +225,14 @@ export class PublishProjectWebViewController extends FormWebviewController< state: PublishDialogState, payload: { deploymentOptions: mssql.DeploymentOptions }, ) => { - // Simply replace entire deploymentOptions - much simpler! + // Update deployment options and regenerate grouped options for UI const newState = { ...state, deploymentOptions: payload.deploymentOptions, }; - // Regenerate grouped options after update - this.state = newState; - this.createGroupedAdvancedOptions(); - newState.groupedAdvancedOptions = this.state.groupedAdvancedOptions; + // Update UI to reflect the changes + this.updateState(newState); return newState; }, @@ -265,6 +260,13 @@ export class PublishProjectWebViewController extends FormWebviewController< const selectedPath = fileUris[0].fsPath; try { + // Check if DacFx service is available for loading deployment options + if (!this._dacFxService) { + void vscode.window.showWarningMessage( + `${Loc.DacFxServiceNotAvailable}. Profile loaded without deployment options.`, + ); + } + // Parse the profile XML to extract all values, including deployment options from DacFx service const parsedProfile = await parsePublishProfileXml( selectedPath, @@ -278,7 +280,7 @@ export class PublishProjectWebViewController extends FormWebviewController< ); // Update state with all parsed values - UI components will consume when available - return { + const newState = { ...state, formState: { ...state.formState, @@ -292,6 +294,11 @@ export class PublishProjectWebViewController extends FormWebviewController< deploymentOptions: parsedProfile.deploymentOptions || state.deploymentOptions, }; + + // Update UI to reflect the changes + this.updateState(newState); + + return newState; } catch (error) { void vscode.window.showErrorMessage( `${Loc.PublishProfileLoadFailed}: ${error}`, @@ -568,88 +575,4 @@ export class PublishProjectWebViewController extends FormWebviewController< return erroredInputs; } - - /** - * Creates grouped advanced options from deployment options - */ - private createGroupedAdvancedOptions(): void { - if (!this.state.deploymentOptions) { - this.state.groupedAdvancedOptions = []; - return; - } - - const groups: { - key: string; - label: string; - entries: { key: string; displayName: string; description: string; value: boolean }[]; - }[] = []; - - // Process boolean options and split into General and Ignore groups - if (this.state.deploymentOptions.booleanOptionsDictionary) { - const allBooleanEntries = Object.entries( - this.state.deploymentOptions.booleanOptionsDictionary, - ).map(([key, option]) => ({ - key, - displayName: option.displayName, - description: option.description, - value: option.value, - })); - - // Split entries into General and Ignore based on displayName starting with "Ignore" - const generalEntries = allBooleanEntries - .filter((entry) => !entry.displayName.startsWith("Ignore")) - .sort((a, b) => a.displayName.localeCompare(b.displayName)); - - const ignoreEntries = allBooleanEntries - .filter((entry) => entry.displayName.startsWith("Ignore")) - .sort((a, b) => a.displayName.localeCompare(b.displayName)); - - // Add General Options group - if (generalEntries.length > 0) { - groups.push({ - key: "General", - label: Loc.GeneralOptions, - entries: generalEntries, - }); - } - - // Add Ignore Options group - if (ignoreEntries.length > 0) { - groups.push({ - key: "Ignore", - label: Loc.IgnoreOptions, - entries: ignoreEntries, - }); - } - } - - // Exclude Object Types group - if (this.state.deploymentOptions.objectTypesDictionary) { - // excludeObjectTypes.value is an array of excluded type names - // We need to show ALL object types with checkboxes (checked = excluded) - const excludedTypes = this.state.deploymentOptions.excludeObjectTypes?.value || []; - - const excludeEntries = Object.entries( - this.state.deploymentOptions.objectTypesDictionary, - ) - .map(([key, displayName]) => ({ - key, - displayName: displayName || key, - description: "", - value: Array.isArray(excludedTypes) ? excludedTypes.includes(key) : false, - })) - .filter((entry) => entry.displayName) // Only include entries with display names - .sort((a, b) => a.displayName.localeCompare(b.displayName)); - - if (excludeEntries.length > 0) { - groups.push({ - key: "Exclude", - label: Loc.ExcludeObjectTypes, - entries: excludeEntries, - }); - } - } - - this.state.groupedAdvancedOptions = groups; - } } diff --git a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx index ab1467f383..acaafe65d1 100644 --- a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx +++ b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx @@ -74,29 +74,104 @@ export const AdvancedDeploymentOptionsDrawer = ({ state.deploymentOptions ? structuredClone(state.deploymentOptions) : undefined, ); - // Update localChanges when drawer opens with fresh data + // Debug: Log when component renders with new state + console.log("DEBUG: AdvancedDeploymentOptionsDrawer render", { + isDrawerOpen: isAdvancedDrawerOpen, + hasStateDeploymentOptions: !!state.deploymentOptions, + hasLocalChanges: !!localChanges, + stateExcludeTypes: state.deploymentOptions?.excludeObjectTypes?.value, + localExcludeTypes: localChanges?.excludeObjectTypes?.value, + }); + + // Update localChanges whenever deploymentOptions change (e.g., from profile loading) React.useEffect(() => { - if (isAdvancedDrawerOpen && state.deploymentOptions) { + console.log("DEBUG: deploymentOptions changed in drawer useEffect", { + hasDeploymentOptions: !!state.deploymentOptions, + excludeObjectTypes: state.deploymentOptions?.excludeObjectTypes?.value, + }); + if (state.deploymentOptions) { setLocalChanges(structuredClone(state.deploymentOptions)); } - }, [isAdvancedDrawerOpen, state.deploymentOptions]); + }, [state.deploymentOptions]); - // Get grouped options and update values from localChanges - const optionGroups = usePublishDialogSelector((s) => { - if (!s.groupedAdvancedOptions || !localChanges) return []; + // Create option groups directly from localChanges (no more groupedAdvancedOptions) + const optionGroups = React.useMemo(() => { + if (!localChanges) return []; - return s.groupedAdvancedOptions.map((group) => ({ - ...group, - entries: group.entries.map((option) => ({ - ...option, - value: - localChanges.booleanOptionsDictionary?.[option.key]?.value ?? - (group.key === "Exclude" - ? (localChanges.excludeObjectTypes?.value?.includes(option.key) ?? false) - : option.value), - })), - })); - }); + const groups: Array<{ + key: string; + label: string; + entries: Array<{ + key: string; + displayName: string; + description: string; + value: boolean; + }>; + }> = []; + + // Process boolean options and split into General and Ignore groups + if (localChanges.booleanOptionsDictionary) { + const allBooleanEntries = Object.entries(localChanges.booleanOptionsDictionary).map( + ([key, option]) => ({ + key, + displayName: option.displayName, + description: option.description, + value: option.value, + }), + ); + + // Split entries into General and Ignore based on displayName starting with "Ignore" + const generalEntries = allBooleanEntries + .filter((entry) => !entry.displayName.startsWith("Ignore")) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + + const ignoreEntries = allBooleanEntries + .filter((entry) => entry.displayName.startsWith("Ignore")) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + + // Add General Options group + if (generalEntries.length > 0) { + groups.push({ + key: "General", + label: loc.publishProject.generalOptions, + entries: generalEntries, + }); + } + + // Add Ignore Options group + if (ignoreEntries.length > 0) { + groups.push({ + key: "Ignore", + label: "Ignore Options", // Use string directly since ignoreOptions doesn't exist + entries: ignoreEntries, + }); + } + } + + // Exclude Object Types group + if (localChanges.objectTypesDictionary) { + const excludedTypes = localChanges.excludeObjectTypes?.value || []; + const excludeEntries = Object.entries(localChanges.objectTypesDictionary) + .map(([key, displayName]) => ({ + key, + displayName: displayName || key, + description: "", + value: Array.isArray(excludedTypes) && excludedTypes.includes(key), + })) + .filter((entry) => entry.displayName) // Only include entries with display names + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + + if (excludeEntries.length > 0) { + groups.push({ + key: "Exclude", + label: loc.publishProject.excludeObjectTypes, + entries: excludeEntries, + }); + } + } + + return groups; + }, [localChanges, loc]); const handleOptionChange = (optionName: string, checked: boolean) => { setLocalChanges((prev) => { diff --git a/src/sharedInterfaces/publishDialog.ts b/src/sharedInterfaces/publishDialog.ts index cd582b4ce6..7cc714a4b0 100644 --- a/src/sharedInterfaces/publishDialog.ts +++ b/src/sharedInterfaces/publishDialog.ts @@ -77,20 +77,6 @@ export interface PublishDialogState connectionString?: string; previousDatabaseList?: { displayName: string; value: string }[]; previousSelectedDatabase?: string; - groupedAdvancedOptions?: DeploymentOptionGroup[]; -} - -export interface DeploymentOptionGroup { - key: string; - label: string; - entries: DeploymentOptionEntry[]; -} - -export interface DeploymentOptionEntry { - key: string; - displayName: string; - description: string; - value: boolean; } /** From 8c0288e8b95035cb216aaadd859291b057ef2721 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 28 Oct 2025 15:09:44 -0500 Subject: [PATCH 86/94] test updates --- .../publishProjectWebViewController.ts | 13 +- .../advancedDeploymentOptionsDrawer.tsx | 44 ++-- .../publishProjectWebViewController.test.ts | 240 +++++++++++++----- 3 files changed, 201 insertions(+), 96 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index d3796687de..cf625fd7a7 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -50,6 +50,11 @@ export class PublishProjectWebViewController extends FormWebviewController< dacFxService?: mssql.IDacFxService, deploymentOptions?: mssql.DeploymentOptions, ) { + // Clear default excludeObjectTypes for publish dialog, no default exclude options should exist + if (deploymentOptions?.excludeObjectTypes !== undefined) { + deploymentOptions.excludeObjectTypes.value = []; + } + super( context, _vscodeWrapper, @@ -97,14 +102,6 @@ export class PublishProjectWebViewController extends FormWebviewController< this._dacFxService = dacFxService; this._connectionManager = connectionManager; - // Clear default excludeObjectTypes for publish dialog, no default exclude options should exist - if ( - this.state.deploymentOptions && - this.state.deploymentOptions.excludeObjectTypes !== undefined - ) { - this.state.deploymentOptions.excludeObjectTypes.value = []; - } - // Register reducers after initialization this.registerRpcHandlers(); diff --git a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx index acaafe65d1..12828580ec 100644 --- a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx +++ b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx @@ -74,21 +74,8 @@ export const AdvancedDeploymentOptionsDrawer = ({ state.deploymentOptions ? structuredClone(state.deploymentOptions) : undefined, ); - // Debug: Log when component renders with new state - console.log("DEBUG: AdvancedDeploymentOptionsDrawer render", { - isDrawerOpen: isAdvancedDrawerOpen, - hasStateDeploymentOptions: !!state.deploymentOptions, - hasLocalChanges: !!localChanges, - stateExcludeTypes: state.deploymentOptions?.excludeObjectTypes?.value, - localExcludeTypes: localChanges?.excludeObjectTypes?.value, - }); - // Update localChanges whenever deploymentOptions change (e.g., from profile loading) React.useEffect(() => { - console.log("DEBUG: deploymentOptions changed in drawer useEffect", { - hasDeploymentOptions: !!state.deploymentOptions, - excludeObjectTypes: state.deploymentOptions?.excludeObjectTypes?.value, - }); if (state.deploymentOptions) { setLocalChanges(structuredClone(state.deploymentOptions)); } @@ -151,13 +138,27 @@ export const AdvancedDeploymentOptionsDrawer = ({ // Exclude Object Types group if (localChanges.objectTypesDictionary) { const excludedTypes = localChanges.excludeObjectTypes?.value || []; + console.log("DEBUG: Creating exclude entries", { + excludedTypes, + objectTypesDictionaryKeys: Object.keys(localChanges.objectTypesDictionary), + }); + const excludeEntries = Object.entries(localChanges.objectTypesDictionary) - .map(([key, displayName]) => ({ - key, - displayName: displayName || key, - description: "", - value: Array.isArray(excludedTypes) && excludedTypes.includes(key), - })) + .map(([key, displayName]) => { + // Case-insensitive comparison: excludedTypes has Pascal case, keys have camel case + const isExcluded = + Array.isArray(excludedTypes) && + excludedTypes.some( + (excludedType) => excludedType.toLowerCase() === key.toLowerCase(), + ); + console.log(`DEBUG: ${key} -> ${displayName} | excluded: ${isExcluded}`); + return { + key, + displayName: displayName || key, + description: "", + value: isExcluded, + }; + }) .filter((entry) => entry.displayName) // Only include entries with display names .sort((a, b) => a.displayName.localeCompare(b.displayName)); @@ -322,7 +323,10 @@ export const AdvancedDeploymentOptionsDrawer = ({
-
+
+ {renderInput(serverComponent, localServer, publishCtx, { + readOnly: true, + contentAfter: ( +
); }; From 94f992430dd8bd5b12cc75257cd1db4ad6678e77 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Wed, 29 Oct 2025 16:27:47 -0500 Subject: [PATCH 90/94] cleanup --- .../pages/PublishProject/components/ConnectionSection.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx index 54bfc604bc..2d71e458fe 100644 --- a/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx +++ b/src/reactviews/pages/PublishProject/components/ConnectionSection.tsx @@ -9,7 +9,6 @@ import { PlugDisconnectedRegular } from "@fluentui/react-icons"; import { PublishProjectContext } from "../publishProjectStateProvider"; import { usePublishDialogSelector } from "../publishDialogSelector"; import { renderInput, renderCombobox } from "./FormFieldComponents"; -import { useFormStyles } from "../../../common/forms/form.component"; const useStyles = makeStyles({ root: { @@ -23,7 +22,6 @@ const useStyles = makeStyles({ export const ConnectionSection: React.FC = () => { const publishCtx = useContext(PublishProjectContext); - const formStyles = useFormStyles(); const styles = useStyles(); const serverComponent = usePublishDialogSelector((s) => s.formComponents.serverName); const databaseComponent = usePublishDialogSelector((s) => s.formComponents.databaseName); From d25af5b4ace5a1b50e881061888d2423d304b63e Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Sat, 1 Nov 2025 01:36:34 -0500 Subject: [PATCH 91/94] final refactoring --- .../publishProjectWebViewController.ts | 13 +++--- .../advancedDeploymentOptionsDrawer.tsx | 41 ++++++++++--------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 2c214a1dc9..bd6d2ca8bc 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -251,13 +251,6 @@ export class PublishProjectWebViewController extends FormWebviewController< const selectedPath = fileUris[0].fsPath; try { - // Check if DacFx service is available for loading deployment options - if (!this._dacFxService) { - void vscode.window.showWarningMessage( - `${Loc.DacFxServiceNotAvailable}. Profile loaded without deployment options.`, - ); - } - // Parse the profile XML to extract all values, including deployment options from DacFx service const parsedProfile = await parsePublishProfileXml( selectedPath, @@ -284,6 +277,12 @@ export class PublishProjectWebViewController extends FormWebviewController< connectionString: parsedProfile.connectionString || state.connectionString, deploymentOptions: parsedProfile.deploymentOptions || state.deploymentOptions, + formMessage: !this._dacFxService + ? { + message: `${Loc.DacFxServiceNotAvailable}. Profile loaded without deployment options.`, + intent: "warning" as const, + } + : undefined, }; // Update UI to reflect the changes diff --git a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx index ab6c0cd153..de1d6b7502 100644 --- a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx +++ b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx @@ -19,7 +19,7 @@ import { makeStyles, } from "@fluentui/react-components"; import { Dismiss24Regular } from "@fluentui/react-icons"; -import React, { useState, useContext } from "react"; +import React, { useState, useContext, useMemo, useEffect, useCallback } from "react"; import { LocConstants } from "../../../common/locConstants"; import { PublishProjectContext } from "../publishProjectStateProvider"; import { usePublishDialogSelector } from "../publishDialogSelector"; @@ -73,16 +73,20 @@ export const AdvancedDeploymentOptionsDrawer = ({ ); // Clear local changes when deploymentOptions change (e.g., from profile loading) - React.useEffect(() => { + useEffect(() => { setLocalChanges([]); }, [state.deploymentOptions]); - const getCurrentValue = (optionName: string, baseValue: boolean): boolean => { - const localChange = localChanges.find((change) => change.optionName === optionName); - return localChange ? localChange.value : baseValue; - }; + + const getCurrentValue = useCallback( + (optionName: string, baseValue: boolean): boolean => { + const localChange = localChanges.find((change) => change.optionName === optionName); + return localChange ? localChange.value : baseValue; + }, + [localChanges], + ); // Create option groups from base deployment options, applying local changes - const optionGroups = React.useMemo(() => { + const optionGroups = useMemo(() => { if (!state.deploymentOptions) return []; const groups: Array<{ @@ -107,7 +111,6 @@ export const AdvancedDeploymentOptionsDrawer = ({ value: getCurrentValue(key, option.value), })); - // Split entries into General and Ignore based on displayName starting with "Ignore" const generalEntries = allBooleanEntries .filter((entry) => !entry.displayName.startsWith("Ignore")) .sort((a, b) => a.displayName.localeCompare(b.displayName)); @@ -138,10 +141,8 @@ export const AdvancedDeploymentOptionsDrawer = ({ // Exclude Object Types group if (state.deploymentOptions.objectTypesDictionary) { const baseExcludedTypes = state.deploymentOptions.excludeObjectTypes?.value || []; - const excludeEntries = Object.entries(state.deploymentOptions.objectTypesDictionary) .map(([key, displayName]) => { - // Get base exclusion state const baseExcluded = baseExcludedTypes.some( (excludedType) => excludedType.toLowerCase() === key.toLowerCase(), ); @@ -166,23 +167,20 @@ export const AdvancedDeploymentOptionsDrawer = ({ } return groups; - }, [state.deploymentOptions, localChanges, loc, getCurrentValue]); + }, [state.deploymentOptions, getCurrentValue, loc]); - // Options change handler, inserts and removed the changed option in localChanges + // Options change handler, inserts and removes the changed option in localChanges const handleOptionChange = (optionName: string, checked: boolean) => { setLocalChanges((prev) => { const existingChange = prev.find((change) => change.optionName === optionName); if (existingChange) { - // Option exists, user is toggling back to original - remove it return prev.filter((change) => change.optionName !== optionName); } else { - // New change, add it return [...prev, { optionName, value: checked }]; } }); }; - // Simple check: disable reset button if no local changes have been made const isResetDisabled = localChanges.length === 0; // Options reset handler, clears all local changes (reset to base deployment options) @@ -203,10 +201,16 @@ export const AdvancedDeploymentOptionsDrawer = ({ } else if (updatedOptions.objectTypesDictionary?.[optionName]) { // Handle exclude object types const excludedTypes = updatedOptions.excludeObjectTypes!.value; - if (value && !excludedTypes.includes(optionName)) { + if ( + value && + !excludedTypes.some((t) => t.toLowerCase() === optionName.toLowerCase()) + ) { excludedTypes.push(optionName); - } else if (!value && excludedTypes.includes(optionName)) { - excludedTypes.splice(excludedTypes.indexOf(optionName), 1); + } else if (!value) { + const index = excludedTypes.findIndex( + (t) => t.toLowerCase() === optionName.toLowerCase(), + ); + if (index !== -1) excludedTypes.splice(index, 1); } } }); @@ -222,7 +226,6 @@ export const AdvancedDeploymentOptionsDrawer = ({ setIsAdvancedDrawerOpen(false); }; - // Helper to check if option matches search const isOptionVisible = (option: { key: string; displayName: string; From a8ee484b04d9c624b5414dce3e92d8570e4409eb Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Sat, 1 Nov 2025 02:53:06 -0500 Subject: [PATCH 92/94] test fix and redundant state update removed --- .../publishProjectWebViewController.ts | 6 ------ test/unit/publishProjectWebViewController.test.ts | 13 ++++++++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index bd6d2ca8bc..435aa3e016 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -222,9 +222,6 @@ export class PublishProjectWebViewController extends FormWebviewController< deploymentOptions: payload.deploymentOptions, }; - // Update UI to reflect the changes - this.updateState(newState); - return newState; }, ); @@ -285,9 +282,6 @@ export class PublishProjectWebViewController extends FormWebviewController< : undefined, }; - // Update UI to reflect the changes - this.updateState(newState); - return newState; } catch (error) { return { diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 249caab3c4..3e9263b2e4 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -334,7 +334,18 @@ suite("PublishProjectWebViewController Tests", () => { description: "", displayName: "", }, - booleanOptionsDictionary: {}, + booleanOptionsDictionary: { + allowIncompatiblePlatform: { + value: true, + description: "Allow incompatible platform", + displayName: "Allow Incompatible Platform", + }, + ignoreComments: { + value: true, + description: "Ignore comments", + displayName: "Ignore Comments", + }, + }, objectTypesDictionary: {}, }, }); From 22aa1a54f469d68d96dea2a4f43c9ba7845d25c1 Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Mon, 3 Nov 2025 11:32:56 -0600 Subject: [PATCH 93/94] updates and loc --- localization/l10n/bundle.l10n.json | 1 + localization/xliff/vscode-mssql.xlf | 3 +++ src/constants/locConstants.ts | 3 +++ src/publishProject/publishProjectWebViewController.ts | 2 +- .../components/advancedDeploymentOptionsDrawer.tsx | 7 +++++-- 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 1f3e3b6f66..42613c7a61 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1492,6 +1492,7 @@ "Publish profile saved to: {0}": "Publish profile saved to: {0}", "Failed to save publish profile": "Failed to save publish profile", "DacFx service is not available": "DacFx service is not available", + "DacFx service is not available. Profile loaded without deployment options.": "DacFx service is not available. Profile loaded without deployment options.", "Failed to list databases": "Failed to list databases", "Schema Compare": "Schema Compare", "Options have changed. Recompare to see the comparison?": "Options have changed. Recompare to see the comparison?", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index ab5cfeea21..54135a45b0 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -970,6 +970,9 @@ DacFx service is not available + + DacFx service is not available. Profile loaded without deployment options. + Data Type diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index c08ad76edc..2f2d4aac2d 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1359,6 +1359,9 @@ export class PublishProject { }; public static PublishProfileSaveFailed = l10n.t("Failed to save publish profile"); public static DacFxServiceNotAvailable = l10n.t("DacFx service is not available"); + public static DacFxServiceNotAvailableProfileLoaded = l10n.t( + "DacFx service is not available. Profile loaded without deployment options.", + ); public static FailedToListDatabases = l10n.t("Failed to list databases"); } diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index 435aa3e016..de2d780b84 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -276,7 +276,7 @@ export class PublishProjectWebViewController extends FormWebviewController< parsedProfile.deploymentOptions || state.deploymentOptions, formMessage: !this._dacFxService ? { - message: `${Loc.DacFxServiceNotAvailable}. Profile loaded without deployment options.`, + message: Loc.DacFxServiceNotAvailableProfileLoaded, intent: "warning" as const, } : undefined, diff --git a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx index de1d6b7502..10ea27d23a 100644 --- a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx +++ b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx @@ -198,9 +198,12 @@ export const AdvancedDeploymentOptionsDrawer = ({ if (updatedOptions.booleanOptionsDictionary?.[optionName]) { // Handle boolean options types updatedOptions.booleanOptionsDictionary[optionName].value = value; - } else if (updatedOptions.objectTypesDictionary?.[optionName]) { + } else if ( + updatedOptions.objectTypesDictionary?.[optionName] && + updatedOptions.excludeObjectTypes + ) { // Handle exclude object types - const excludedTypes = updatedOptions.excludeObjectTypes!.value; + const excludedTypes = updatedOptions.excludeObjectTypes.value; if ( value && !excludedTypes.some((t) => t.toLowerCase() === optionName.toLowerCase()) From 9e8d9b947b2484bd97697319acf8c4c828d2450e Mon Sep 17 00:00:00 2001 From: Sai Avishkar Sreerama Date: Tue, 4 Nov 2025 17:22:11 -0600 Subject: [PATCH 94/94] addressing review comments by refactoring and updating tests --- localization/l10n/bundle.l10n.json | 4 +- localization/xliff/vscode-mssql.xlf | 8 +- src/constants/locConstants.ts | 11 +-- .../publishProjectWebViewController.ts | 7 +- .../advancedDeploymentOptionsDrawer.tsx | 84 ++++++++++++------- .../publishProjectWebViewController.test.ts | 47 ++++++++--- 6 files changed, 100 insertions(+), 61 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 42613c7a61..abc913ae34 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1491,8 +1491,8 @@ "Failed to load publish profile": "Failed to load publish profile", "Publish profile saved to: {0}": "Publish profile saved to: {0}", "Failed to save publish profile": "Failed to save publish profile", - "DacFx service is not available": "DacFx service is not available", - "DacFx service is not available. Profile loaded without deployment options.": "DacFx service is not available. Profile loaded without deployment options.", + "DacFx service is not available. Publish and generate script operations cannot be performed.": "DacFx service is not available. Publish and generate script operations cannot be performed.", + "DacFx service is not available. Profile loaded without deployment options. Publish and generate script operations cannot be performed.": "DacFx service is not available. Profile loaded without deployment options. Publish and generate script operations cannot be performed.", "Failed to list databases": "Failed to list databases", "Schema Compare": "Schema Compare", "Options have changed. Recompare to see the comparison?": "Options have changed. Recompare to see the comparison?", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 21796c9045..72fd189d67 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -967,11 +967,11 @@ Custom Zoom - - DacFx service is not available + + DacFx service is not available. Profile loaded without deployment options. Publish and generate script operations cannot be performed. - - DacFx service is not available. Profile loaded without deployment options. + + DacFx service is not available. Publish and generate script operations cannot be performed. Data Type diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index 2f2d4aac2d..537e6989f0 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1329,11 +1329,6 @@ export class PublishProject { public static PublishTargetNewAzureServer = l10n.t("New Azure SQL logical server (Preview)"); public static GenerateScript = l10n.t("Generate Script"); public static Publish = l10n.t("Publish"); - public static AdvancedOptions = l10n.t("Advanced"); - public static AdvancedPublishSettings = l10n.t("Advanced Publish Options"); - public static GeneralOptions = l10n.t("General Options"); - public static IgnoreOptions = l10n.t("Ignore Options"); - public static ExcludeObjectTypes = l10n.t("Exclude Object Types"); public static SqlServerPortNumber = l10n.t("SQL Server port number"); public static SqlServerAdminPassword = l10n.t("SQL Server admin password"); public static SqlServerAdminPasswordConfirm = l10n.t("Confirm SQL Server admin password"); @@ -1358,9 +1353,11 @@ export class PublishProject { return l10n.t("Publish profile saved to: {0}", path); }; public static PublishProfileSaveFailed = l10n.t("Failed to save publish profile"); - public static DacFxServiceNotAvailable = l10n.t("DacFx service is not available"); + public static DacFxServiceNotAvailable = l10n.t( + "DacFx service is not available. Publish and generate script operations cannot be performed.", + ); public static DacFxServiceNotAvailableProfileLoaded = l10n.t( - "DacFx service is not available. Profile loaded without deployment options.", + "DacFx service is not available. Profile loaded without deployment options. Publish and generate script operations cannot be performed.", ); public static FailedToListDatabases = l10n.t("Failed to list databases"); } diff --git a/src/publishProject/publishProjectWebViewController.ts b/src/publishProject/publishProjectWebViewController.ts index de2d780b84..973be1423b 100644 --- a/src/publishProject/publishProjectWebViewController.ts +++ b/src/publishProject/publishProjectWebViewController.ts @@ -260,8 +260,7 @@ export class PublishProjectWebViewController extends FormWebviewController< TelemetryActions.PublishProfileLoaded, ); - // Update state with all parsed values - UI components will consume when available - const newState = { + return { ...state, formState: { ...state.formState, @@ -277,12 +276,10 @@ export class PublishProjectWebViewController extends FormWebviewController< formMessage: !this._dacFxService ? { message: Loc.DacFxServiceNotAvailableProfileLoaded, - intent: "warning" as const, + intent: "error" as const, } : undefined, }; - - return newState; } catch (error) { return { ...state, diff --git a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx index 10ea27d23a..985bed6974 100644 --- a/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx +++ b/src/reactviews/pages/PublishProject/components/advancedDeploymentOptionsDrawer.tsx @@ -188,41 +188,65 @@ export const AdvancedDeploymentOptionsDrawer = ({ setLocalChanges([]); }; - // Options 'OK' button handler, apply local changes to create updated deployment options + // Handle ok button click, Apply local changes and close drawer const handleOk = () => { - if (state.deploymentOptions && localChanges.length > 0) { - const updatedOptions = structuredClone(state.deploymentOptions); - - // Apply each local change - localChanges.forEach(({ optionName, value }) => { - if (updatedOptions.booleanOptionsDictionary?.[optionName]) { - // Handle boolean options types - updatedOptions.booleanOptionsDictionary[optionName].value = value; - } else if ( - updatedOptions.objectTypesDictionary?.[optionName] && - updatedOptions.excludeObjectTypes - ) { - // Handle exclude object types - const excludedTypes = updatedOptions.excludeObjectTypes.value; - if ( - value && - !excludedTypes.some((t) => t.toLowerCase() === optionName.toLowerCase()) - ) { - excludedTypes.push(optionName); - } else if (!value) { - const index = excludedTypes.findIndex( - (t) => t.toLowerCase() === optionName.toLowerCase(), - ); - if (index !== -1) excludedTypes.splice(index, 1); - } - } - }); - - context?.updateDeploymentOptions(updatedOptions); + if (!state.deploymentOptions || localChanges.length === 0) { + setIsAdvancedDrawerOpen(false); + return; } + + const updatedOptions = structuredClone(state.deploymentOptions); + + // Apply each local change to the deployment options + localChanges.forEach(({ optionName, value }) => { + // Case 1: Boolean deployment option + if (updatedOptions.booleanOptionsDictionary?.[optionName]) { + updatedOptions.booleanOptionsDictionary[optionName].value = value; + return; + } + + // Case 2: Exclude object type + if ( + updatedOptions.objectTypesDictionary?.[optionName] && + updatedOptions.excludeObjectTypes + ) { + updateExcludedObjectTypes( + updatedOptions.excludeObjectTypes.value, + optionName, + value, + ); + } + }); + + // Send updated options back to parent component + context?.updateDeploymentOptions(updatedOptions); setIsAdvancedDrawerOpen(false); }; + // Add or remove object type from exclude objects exclusion list + const updateExcludedObjectTypes = ( + excludedTypes: string[], + optionName: string, + shouldExclude: boolean, + ) => { + const isCurrentlyExcluded = excludedTypes.some( + (type) => type.toLowerCase() === optionName.toLowerCase(), + ); + + if (shouldExclude && !isCurrentlyExcluded) { + // Add to exclusion list + excludedTypes.push(optionName); + } else if (!shouldExclude && isCurrentlyExcluded) { + // Remove from exclusion list + const index = excludedTypes.findIndex( + (type) => type.toLowerCase() === optionName.toLowerCase(), + ); + if (index !== -1) { + excludedTypes.splice(index, 1); + } + } + }; + // Clear local changes and close drawer const handleCancel = () => { setLocalChanges([]); diff --git a/test/unit/publishProjectWebViewController.test.ts b/test/unit/publishProjectWebViewController.test.ts index 3e9263b2e4..5a238de80c 100644 --- a/test/unit/publishProjectWebViewController.test.ts +++ b/test/unit/publishProjectWebViewController.test.ts @@ -195,8 +195,14 @@ suite("PublishProjectWebViewController Tests", () => { expect(controller.state.formState.publishTarget).to.equal(PublishTarget.LocalContainer); // Test that changing publish target updates field visibility - expect(controller.state.formComponents.containerPort?.hidden).to.not.be.true; - expect(controller.state.formComponents.serverName?.hidden).to.be.true; + expect( + controller.state.formComponents.containerPort?.hidden, + "containerPort should be visible for LocalContainer target", + ).to.not.be.true; + expect( + controller.state.formComponents.serverName?.hidden, + "serverName should be hidden for LocalContainer target", + ).to.be.true; }); test("Azure SQL project shows Azure-specific labels", async () => { @@ -367,15 +373,22 @@ suite("PublishProjectWebViewController Tests", () => { }); // Verify deployment options were loaded from DacFx matching XML properties - expect(mockDacFxService.getOptionsFromProfile.calledOnce).to.be.true; + expect( + mockDacFxService.getOptionsFromProfile.calledOnce, + "DacFx getOptionsFromProfile should be called once when loading profile", + ).to.be.true; expect(newState.deploymentOptions.excludeObjectTypes.value).to.deep.equal([ "Users", "Logins", ]); - expect(newState.deploymentOptions.booleanOptionsDictionary.allowIncompatiblePlatform?.value) - .to.be.true; - expect(newState.deploymentOptions.booleanOptionsDictionary.ignoreComments?.value).to.be - .true; + expect( + newState.deploymentOptions.booleanOptionsDictionary.allowIncompatiblePlatform?.value, + "allowIncompatiblePlatform should be true from parsed profile", + ).to.be.true; + expect( + newState.deploymentOptions.booleanOptionsDictionary.ignoreComments?.value, + "ignoreComments should be true from parsed profile", + ).to.be.true; }); test("savePublishProfile reducer is invoked and triggers save file dialog", async () => { @@ -432,7 +445,10 @@ suite("PublishProjectWebViewController Tests", () => { }); // Verify DacFx save was called with correct parameters - expect(mockDacFxService.savePublishProfile.calledOnce).to.be.true; + expect( + mockDacFxService.savePublishProfile.calledOnce, + "DacFx savePublishProfile should be called once when saving profile", + ).to.be.true; const saveCall = mockDacFxService.savePublishProfile.getCall(0); expect(saveCall.args[0].replace(/\\/g, "/")).to.equal(savedProfilePath); // File path (normalize for cross-platform) @@ -445,9 +461,14 @@ suite("PublishProjectWebViewController Tests", () => { const deploymentOptions = saveCall.args[4]; expect(deploymentOptions).to.exist; expect(deploymentOptions.excludeObjectTypes.value).to.deep.equal(["Users", "Permissions"]); - expect(deploymentOptions.booleanOptionsDictionary.ignoreTableOptions?.value).to.be.true; - expect(deploymentOptions.booleanOptionsDictionary.allowIncompatiblePlatform?.value).to.be - .false; + expect( + deploymentOptions.booleanOptionsDictionary.ignoreTableOptions?.value, + "ignoreTableOptions should be true in saved deployment options", + ).to.be.true; + expect( + deploymentOptions.booleanOptionsDictionary.allowIncompatiblePlatform?.value, + "allowIncompatiblePlatform should be false in saved deployment options", + ).to.be.false; }); //#endregion @@ -461,14 +482,14 @@ suite("PublishProjectWebViewController Tests", () => { const serverComponent = controller.state.formComponents.serverName; expect(serverComponent).to.exist; expect(serverComponent.label).to.exist; - expect(serverComponent.required).to.be.true; + expect(serverComponent.required, "serverName component should be required").to.be.true; expect(controller.state.formState.serverName).to.equal(""); // Verify database component and default value (project name) const databaseComponent = controller.state.formComponents.databaseName; expect(databaseComponent).to.exist; expect(databaseComponent.label).to.exist; - expect(databaseComponent.required).to.be.true; + expect(databaseComponent.required, "databaseName component should be required").to.be.true; expect(controller.state.formState.databaseName).to.equal("MyTestProject"); });