diff --git a/packages/node-cli/src/ApplicationConstants.js b/packages/node-cli/src/ApplicationConstants.js index b58d353c..9ae32ef5 100644 --- a/packages/node-cli/src/ApplicationConstants.js +++ b/packages/node-cli/src/ApplicationConstants.js @@ -68,5 +68,11 @@ module.exports = { SUITECLOUD_CI: 'SUITECLOUD_CI', SUITECLOUD_CI_PASSKEY: 'SUITECLOUD_CI_PASSKEY', SUITECLOUD_FALLBACK_PASSKEY: 'SUITECLOUD_FALLBACK_PASSKEY' - } + }, + HTTP_RESPONSE_CODE: { + UNAUTHORIZED: 401, + FORBIDDEN: 403, + INTERNAL_SERVER_ERROR: 500, + SERVICE_UNAVAILABLE: 503, + }, }; diff --git a/packages/node-cli/src/services/SuiteCloudAuthProxyService.js b/packages/node-cli/src/services/SuiteCloudAuthProxyService.js index 533afb93..1dfb7a0b 100644 --- a/packages/node-cli/src/services/SuiteCloudAuthProxyService.js +++ b/packages/node-cli/src/services/SuiteCloudAuthProxyService.js @@ -25,6 +25,7 @@ const { } = require('../utils/AuthenticationUtils'); const { AUTHORIZATION_PROPERTIES_KEYS, + HTTP_RESPONSE_CODE, } = require('../ApplicationConstants'); /** Message literal service method */ @@ -39,14 +40,6 @@ const LOCAL_HOSTNAME = '127.0.0.1'; /** Target server port */ const TARGET_SERVER_PORT = 443; -/** Http codes */ -const HTTP_RESPONSE_CODE = { - UNAUTHORIZED: 401, - FORBIDDEN: 403, - INTERNAL_SERVER_ERROR: 500, - SERVICE_UNAVAILABLE: 503, -}; - class SuiteCloudAuthProxyService extends EventEmitter { constructor(sdkPath, executionEnvironmentContext) { super(); diff --git a/packages/vscode-extension/messages.json b/packages/vscode-extension/messages.json index 64c0fc5a..39c9c433 100644 --- a/packages/vscode-extension/messages.json +++ b/packages/vscode-extension/messages.json @@ -10,9 +10,12 @@ "ANSWERS_NO": "No", "ANSWERS_YES": "Yes", + "BUTTONS_DONT_SHOW_AGAIN": "Don't show again", + "BUTTONS_GIVE_FEEDBACK": "Give Feedback", + "BUTTONS_OK": "Ok", + "BUTTONS_OPEN_SETTINGS": "Open Settings", "BUTTONS_RESTART_NOW": "Restart Now", "BUTTONS_SEE_DETAILS": "See Details", - "BUTTONS_OK": "Ok", "COMMAND_ERROR": "Something went wrong with '{0}'.", "COMMAND_SUCCESS": "'{0}' has been successfully executed.", @@ -54,6 +57,17 @@ "DEPLOY_QUESTIONS_CHOICES_ACCOUNT_SPECIFIC_VALUES_CANCEL_PROCESS": "Cancel the deployment process", "DEPLOY_QUESTIONS_CHOICES_ACCOUNT_SPECIFIC_VALUES_DISPLAY_WARNING": "Display a warning and continue the deployment process", + "DEVASSIST_SERVICE_FEEDBACK_FORM_GENERIC_VALIDATION_ERROR_WRAPPER": "Some fields have validation errors.
{0}", + "DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_VALIDATION_ERROR": "{0}: {1}", + "DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_CANNOT_BE_EMPTY_TEXT": "This field cannot not be empty.", + "DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_CANNOT_BE_EMPTY_MULTIPLE_OPTION": "At least one of the available options must be selected.", + "DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_CANNOT_BE_EMPTY_NUMERIC": "Choose a satisfaction level (for example, 3 out of 5 stars).", + "DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_CANNOT_BE_TOO_LONG": "This field can only contain up to {0} characters.", + "DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_CANNOT_HAVE_REPEATED_VALUES": "Select unique values (provided: {0}).", + "DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_MUST_HAVE_BE_A_VALID_NUMBER": "Choose a natural number between {0} and {1}.", + "DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_MUST_HAVE_SPECIFIC_VALUES": "Select any values from the ones available: {0}.", + "DEVASSIST_SERVICE_FEEDBACK_FORM_SUBMITTING_ERROR": "Unable to connect to the SuiteCloud Developer Assistant service. Is it running?", + "DEVASSIST_SERVICE_OUTPUT_PROXY_ERROR": "There was a problem while running SuiteCloud Developer Assistant service.\n{0}", "DEVASSIST_SERVICE_OUTPUT_SERVER_ERROR": "There was a server error while running SuiteCloud Developer Assistant service.\n{0}", "DEVASSIST_SERVICE_OUTPUT_SERVER_ERROR_ON_REFRESH": "There was a problem refreshing credentials while running SuiteCloud Developer Assistant service.\n{0}", @@ -66,13 +80,9 @@ "DEVASSIST_SERVICE_IS_STOPPED_OUTPUT": "There was a problem when starting SuiteCloud Developer Assistant service.\n{0}", "DEVASSIST_SERVICE_IS_STOPPED_STATUSBAR": "Dev Assist: stopped", "DEVASSIST_SERVICE_SERVER_ERROR_OUTPUT": "A server error has occurred while running SuiteCloud Developer Assistant service.\nError: {0}", - "DEVASSIST_SERVICE_STARTUP_BUTTON_DONT_SHOW_AGAIN": "Don't show again", - "DEVASSIST_SERVICE_STARTUP_BUTTON_OPEN_SETTINGS": "Open Settings", - "DEVASSIST_SERVICE_SEE_DETAILS_BUTTON": "See Details", "DEVASSIST_SERVICE_STARTUP_MESSAGE": "SuiteCloud Developer Assistant is here. Open settings to start using it.", "DEVASSIST_SERVICE_STATUSBAR_TOOLTIP": "Shows the status of SuiteCloud Developer Assistant service.", - "DISMISS": "Dismiss", "ERRORS_COURRUPTED_SDK_JAR_DEPENDENCY": "There was a problem with SuiteCloud Extension dependencies. Restart your Visual Studio Code instance.", diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 2416a6f9..226d12ba 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -111,7 +111,13 @@ "title": "Open DevAssist settings", "category": "SuiteCloud", "enablement": "false" - } + }, + { + "command": "suitecloud.opendevassistfeedbackform", + "title": "Open DevAssist Feedback Form", + "category": "SuiteCloud", + "enablement": "false" + } ], "keybindings": [ { diff --git a/packages/vscode-extension/resources/media/FeedbackForm.css b/packages/vscode-extension/resources/media/FeedbackForm.css new file mode 100644 index 00000000..bd93cc24 --- /dev/null +++ b/packages/vscode-extension/resources/media/FeedbackForm.css @@ -0,0 +1,229 @@ +:root { + --gap: 12px; + --radius: 12px; + --border: #e5e7eb; + --text: #111827; + --muted: #6b7280 +} + +* { + box-sizing: border-box +} + +body { + font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, sans-serif; + color: var(--text); + background: #f8fafc; + margin: 0; + padding: 24px +} + +.card { + max-width: 760px; + margin: auto; + background: #fff; + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 10px 25px rgba(0, 0, 0, .05) +} + +.card header { + padding: 20px 24px; + border-bottom: 1px solid var(--border) +} + +h1 { + font-size: 20px; + margin: 0 +} + +.content { + padding: 20px 24px; + display: grid; + gap: var(--gap) +} + +label { + font-weight: 600 +} + +textarea { + width: 100%; + min-height: 120px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + resize: vertical; + font: inherit +} + +fieldset { + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px +} + +legend { + padding: 0 6px; + color: var(--muted) +} + +.checks { + display: grid; + grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px 14px; + margin-top: 6px +} + +.checks label { + font-weight: 500; + display: flex; + align-items: center; + gap: 8px +} + +.checks input { + transform: translateY(1px) +} + +.stars { + display: inline-flex; + flex-direction: row-reverse; /* shows stars in correct LTR order */ + gap: 6px; + width: fit-content; +} +.stars input { + display: none; +} +.star { + cursor: pointer; + font-size: 28px; + line-height: 1; + filter: grayscale(.6); + transition: transform .05s ease; +} +.star::before { + content: "☆"; +} +/* On hover, fill all stars to the left (and including hovered) */ +.stars label.star:hover, +.stars label.star:hover ~ label.star { + transform: scale(1.25); +} +.stars label.star:hover::before, +.stars label.star:hover ~ label.star::before { + content: "★"; + color: #f59e0b; + filter: none; +} +.stars label.star:hover, +.stars label.star:hover ~ label.star { + color: #f59e0b; + filter: none; +} + +/* On checked, fill all stars to the left (and including selected) */ +.stars input:checked ~ label.star::before, +.stars input:checked ~ label.star { + content: "★"; + color: #f59e0b; + filter: none; +} + +.row { + display: grid; + gap: 6px +} + +button { + appearance: none; + border: 1px solid var(--border); + background: #111827; + color: #fff; + padding: 10px 14px; + border-radius: 10px; + font-weight: 600; + cursor: pointer +} + +button.secondary { + background: #fff; + color: #111827 +} + +.note { + color: var(--muted); + font-size: 12px +} + +pre { + background: #0b1020; + color: #d1d5db; + padding: 12px; + border-radius: 10px; + overflow: auto; + max-height: 240px +} + +.actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 8px +} + +#alert-container { + position: fixed; + z-index: 9999; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column-reverse; /* new alerts on top */ + gap: 12px; + max-width: 860px; + margin-left: 64px; + pointer-events: none; +} +.toast-alert { + display: flex; + align-items: start; + background: #e7f5ff; + color: #155577; + border-left: 4px solid #228be6; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,0.13); + margin: 0; + padding: 16px 44px 16px 16px; + font-size: 15px; + position: relative; + min-width: 220px; + max-width: 100%; + pointer-events: auto; +} +.toast-alert.toast-info { + background: #e7f5ff; + border-color: #228be6; + color: #155577; +} +.toast-alert.toast-error { + background: #ffceca; + border-color: #ff4d4f; + color: #a8071a; +} +.toast-close { + position: absolute; + top: 8px; + right: 10px; + background: none; + border: none; + color: inherit; + font-size: 19px; + cursor: pointer; + line-height: 1; + padding: 0; + opacity: 0.65; + transition: opacity 0.19s; +} +.toast-close:hover { + opacity: 1; +} \ No newline at end of file diff --git a/packages/vscode-extension/resources/media/FeedbackForm.html b/packages/vscode-extension/resources/media/FeedbackForm.html new file mode 100644 index 00000000..3c59b534 --- /dev/null +++ b/packages/vscode-extension/resources/media/FeedbackForm.html @@ -0,0 +1,131 @@ + + + + + DevAssist Feedback + + + +
+
+

SuiteCloud Developer Assistant Feedback

+

Take a moment to share your thoughts on the SuiteCloud Developer Assistant.

+
+
+
+ + +
+ +
+ This feedback is about +
+ + + + + +
+
+ +
+ +
+ + + + + + + + + + +
+
+ +
+ + +
+
+
+
+ + + \ No newline at end of file diff --git a/packages/vscode-extension/resources/media/FeedbackFormFailure.html b/packages/vscode-extension/resources/media/FeedbackFormFailure.html new file mode 100644 index 00000000..7fc888cc --- /dev/null +++ b/packages/vscode-extension/resources/media/FeedbackFormFailure.html @@ -0,0 +1,36 @@ + + + + + DevAssist Feedback + + + +
+
+

SuiteCloud Developer Assistant Feedback

+

+ Take a moment to share your thoughts on the SuiteCloud Developer Assistant.

+
+
+ + +
+ +
+
+
+ + + \ No newline at end of file diff --git a/packages/vscode-extension/resources/media/FeedbackFormSubmitting.html b/packages/vscode-extension/resources/media/FeedbackFormSubmitting.html new file mode 100644 index 00000000..ef087f44 --- /dev/null +++ b/packages/vscode-extension/resources/media/FeedbackFormSubmitting.html @@ -0,0 +1,34 @@ + + + + + DevAssist Feedback + + + +
+
+

SuiteCloud Developer Assistant Feedback

+

+ Take a moment to share your thoughts on the SuiteCloud Developer Assistant.

+
+
+ +
+
+ + + \ No newline at end of file diff --git a/packages/vscode-extension/resources/media/FeedbackFormSuccess.html b/packages/vscode-extension/resources/media/FeedbackFormSuccess.html new file mode 100644 index 00000000..50d14677 --- /dev/null +++ b/packages/vscode-extension/resources/media/FeedbackFormSuccess.html @@ -0,0 +1,42 @@ + + + + + DevAssist Feedback + + + +
+
+

SuiteCloud Developer Assistant Feedback

+

+ Take a moment to share your thoughts on the SuiteCloud Developer Assistant.

+
+
+ +
+ + +
+
+
+ + + \ No newline at end of file diff --git a/packages/vscode-extension/src/ApplicationConstants.ts b/packages/vscode-extension/src/ApplicationConstants.ts index feab37f5..ec3b7e17 100644 --- a/packages/vscode-extension/src/ApplicationConstants.ts +++ b/packages/vscode-extension/src/ApplicationConstants.ts @@ -47,6 +47,7 @@ export const DEVASSIST = { PROXY_URL: { SCHEME: 'http://', LOCALHOST_IP: '127.0.0.1', - PATH: '/api/internal/devassist' + BASE_PATH: '/api/internal/devassist', + FEEDBACK_PATH: '/api/internal/devassist/feedback', } } diff --git a/packages/vscode-extension/src/service/MediaFileKeys.ts b/packages/vscode-extension/src/service/MediaFileKeys.ts new file mode 100644 index 00000000..1e369443 --- /dev/null +++ b/packages/vscode-extension/src/service/MediaFileKeys.ts @@ -0,0 +1,16 @@ +/* + ** Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. + */ + +export const MEDIA_DIRECTORY = 'resources/media'; + +export const FEEDBACK_FORM_FILE_NAMES = { + MAIN_PAGE : { + HTML : 'FeedbackForm.html', + CSS : 'FeedbackForm.css' + }, + SUBMITTING_HTML : 'FeedbackFormSubmitting.html', + SUCCESS_HTML : 'FeedbackFormSuccess.html', + FAILURE_HTML : 'FeedbackFormFailure.html', +} \ No newline at end of file diff --git a/packages/vscode-extension/src/service/MediaFileService.ts b/packages/vscode-extension/src/service/MediaFileService.ts new file mode 100644 index 00000000..e6295ff8 --- /dev/null +++ b/packages/vscode-extension/src/service/MediaFileService.ts @@ -0,0 +1,33 @@ +/* + ** Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. + */ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import { FileUtils } from '../util/ExtensionUtil'; +import { MEDIA_DIRECTORY } from './MediaFileKeys'; + +export class MediaFileService { + private vscodeExtensionMediaPath : string; + + constructor(context: vscode.ExtensionContext) { + this.vscodeExtensionMediaPath = path.join(context.extensionPath, MEDIA_DIRECTORY); + } + + public getMediaDirectoryFullPath = () => { + return this.vscodeExtensionMediaPath; + } + + public getMediaFileFullPath = (fileName : string) => { + return path.join(this.vscodeExtensionMediaPath, fileName); + } + + public generateHTMLContentFromMediaFile = (htmlFileName : string, cssWebviewUri : string) => { + const htmlFilePath = this.getMediaFileFullPath(htmlFileName); + let htmlFileContent = FileUtils.readAsString(htmlFilePath); + + htmlFileContent = htmlFileContent.replace('{{CSS_FILE.css}}', cssWebviewUri); + return htmlFileContent; + } +} \ No newline at end of file diff --git a/packages/vscode-extension/src/service/MessageService.ts b/packages/vscode-extension/src/service/MessageService.ts index 4097051c..2fcbf66e 100644 --- a/packages/vscode-extension/src/service/MessageService.ts +++ b/packages/vscode-extension/src/service/MessageService.ts @@ -138,8 +138,12 @@ export default class MessageService { private showOutputIfClicked = (message?: string) => { if (message) { - output.show(); + this.showOutput(); } } + public showOutput = () => { + output.show(); + } + } diff --git a/packages/vscode-extension/src/service/TranslationKeys.ts b/packages/vscode-extension/src/service/TranslationKeys.ts index 87bbbf50..d7a2e4dd 100644 --- a/packages/vscode-extension/src/service/TranslationKeys.ts +++ b/packages/vscode-extension/src/service/TranslationKeys.ts @@ -20,9 +20,12 @@ export const ANSWERS = { }; export const BUTTONS = { + DONT_SHOW_AGAIN: "BUTTONS_DONT_SHOW_AGAIN", + GIVE_FEEDBACK: "BUTTONS_GIVE_FEEDBACK", + OK: 'BUTTONS_OK', + OPEN_SETTINGS: "BUTTONS_OPEN_SETTINGS", RESTART_NOW: 'BUTTONS_RESTART_NOW', SEE_DETAILS: 'BUTTONS_SEE_DETAILS', - OK: 'BUTTONS_OK' }; export const COMMAND = { @@ -105,6 +108,20 @@ export const DEVASSIST_SERVICE = { SERVER_ERROR_ON_REFRESH: 'DEVASSIST_SERVICE_OUTPUT_SERVER_ERROR_ON_REFRESH', } }, + FEEDBACK_FORM: { + GENERIC_VALIDATION_ERROR_WRAPPER: 'DEVASSIST_SERVICE_FEEDBACK_FORM_GENERIC_VALIDATION_ERROR_WRAPPER', + FIELD: { + VALIDATION_ERROR: 'DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_VALIDATION_ERROR', + CANNOT_BE_EMPTY_TEXT: 'DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_CANNOT_BE_EMPTY_TEXT', + CANNOT_BE_EMPTY_MULTIPLE_OPTION: 'DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_CANNOT_BE_EMPTY_MULTIPLE_OPTION', + CANNOT_BE_EMPTY_NUMERIC: 'DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_CANNOT_BE_EMPTY_NUMERIC', + CANNOT_BE_TOO_LONG: 'DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_CANNOT_BE_TOO_LONG', + CANNOT_HAVE_REPEATED_VALUES: 'DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_CANNOT_HAVE_REPEATED_VALUES', + MUST_HAVE_BE_A_VALID_NUMBER: 'DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_MUST_HAVE_BE_A_VALID_NUMBER', + MUST_HAVE_SPECIFIC_VALUES: 'DEVASSIST_SERVICE_FEEDBACK_FORM_FIELD_MUST_HAVE_SPECIFIC_VALUES', + }, + SUBMITTING_ERROR: 'DEVASSIST_SERVICE_FEEDBACK_FORM_SUBMITTING_ERROR' + }, IS_DISABLED: { NOTIFICATION: 'DEVASSIST_SERVICE_IS_DISABLED_NOTIFICATION', OUTPUT: 'DEVASSIST_SERVICE_IS_DISABLED_OUTPUT', @@ -120,14 +137,9 @@ export const DEVASSIST_SERVICE = { STATUSBAR: 'DEVASSIST_SERVICE_IS_STOPPED_STATUSBAR' }, STARTUP: { - BUTTON: { - DONT_SHOW_AGAIN: 'DEVASSIST_SERVICE_STARTUP_BUTTON_DONT_SHOW_AGAIN', - OPEN_SETTINGS: 'DEVASSIST_SERVICE_STARTUP_BUTTON_OPEN_SETTINGS', - }, MESSAGE: 'DEVASSIST_SERVICE_STARTUP_MESSAGE', }, STATUSBAR_TOOLTIP: 'DEVASSIST_SERVICE_STATUSBAR_TOOLTIP', - SEE_DETAILS_BUTTON: 'DEVASSIST_SERVICE_SEE_DETAILS_BUTTON' } export const DISMISS = 'DISMISS'; diff --git a/packages/vscode-extension/src/startup/DevAssistConfiguration.ts b/packages/vscode-extension/src/startup/DevAssistConfiguration.ts index 86d2e15b..029b3891 100644 --- a/packages/vscode-extension/src/startup/DevAssistConfiguration.ts +++ b/packages/vscode-extension/src/startup/DevAssistConfiguration.ts @@ -3,7 +3,7 @@ import { DEVASSIST, VSCODE_PLATFORM } from '../ApplicationConstants'; import { getSdkPath } from '../core/sdksetup/SdkProperties'; import VSConsoleLogger from "../loggers/VSConsoleLogger"; import MessageService from '../service/MessageService'; -import { DEVASSIST_SERVICE, REFRESH_AUTHORIZATION } from '../service/TranslationKeys'; +import { BUTTONS, DEVASSIST_SERVICE, REFRESH_AUTHORIZATION } from '../service/TranslationKeys'; import { VSTranslationService } from '../service/VSTranslationService'; import { AuthenticationUtils, ExecutionEnvironmentContext, SuiteCloudAuthProxyService } from '../util/ExtensionUtil'; import { output } from '../suitecloud'; @@ -148,15 +148,16 @@ const initializeDevAssistService = (devAssistStatusBar: vscode.StatusBarItem) => const startDevAssistService = async (devAssistAuthID: string, localPort: number, devAssistStatusBar: vscode.StatusBarItem) => { await devAssistProxyService.start(devAssistAuthID, localPort); + const proxyUrl = getProxyUrl(localPort); setSuccessDevAssistStausBarMessage(devAssistStatusBar); - const proxyUrl = getProxyUrl(localPort); - vsNotificationService.showCommandInfo(translationService.getMessage(DEVASSIST_SERVICE.IS_RUNNING.NOTIFICATION, proxyUrl)); + showDevAssistIsRunningNotification(proxyUrl); + vsLogger.printTimestamp(); vsLogger.info(translationService.getMessage(DEVASSIST_SERVICE.IS_RUNNING.OUTPUT, getProxyUrlWithoutPath(localPort), devAssistAuthID, proxyUrl)); } const PROXY_URL = DEVASSIST.PROXY_URL; -const getProxyUrl = (port: number) => `${PROXY_URL.SCHEME}${PROXY_URL.LOCALHOST_IP}:${port}${PROXY_URL.PATH}`; +const getProxyUrl = (port: number) => `${PROXY_URL.SCHEME}${PROXY_URL.LOCALHOST_IP}:${port}${PROXY_URL.BASE_PATH}`; const getProxyUrlWithoutPath = (port: number) => `${PROXY_URL.SCHEME}${PROXY_URL.LOCALHOST_IP}:${port}`; const stopDevAssistService = (devAssistStatusBar: vscode.StatusBarItem) => { @@ -178,11 +179,11 @@ const showDevAssistStartUpNotification = () => { const infoMessage: string = translationService.getMessage(DEVASSIST_SERVICE.STARTUP.MESSAGE); const buttonsAndActions: { buttonMessage: string, buttonAction: () => void }[] = [ { - buttonMessage: translationService.getMessage(DEVASSIST_SERVICE.STARTUP.BUTTON.OPEN_SETTINGS), + buttonMessage: translationService.getMessage(BUTTONS.OPEN_SETTINGS), buttonAction: openDevAssistSettings }, { - buttonMessage: translationService.getMessage(DEVASSIST_SERVICE.STARTUP.BUTTON.DONT_SHOW_AGAIN), + buttonMessage: translationService.getMessage(BUTTONS.DONT_SHOW_AGAIN), buttonAction: () => { const devAssistConfigSection = vscode.workspace.getConfiguration(DEVASSIST.CONFIG_KEYS.devAssistSection); devAssistConfigSection.update(DEVASSIST.CONFIG_KEYS.startupNotificationDisabled, true); @@ -192,6 +193,23 @@ const showDevAssistStartUpNotification = () => { vsNotificationService.showCommandInfoWithSpecificButtonsAndActions(infoMessage, buttonsAndActions); } +const showDevAssistIsRunningNotification = (proxyUrl : string) => { + const infoMessage: string = translationService.getMessage(DEVASSIST_SERVICE.IS_RUNNING.NOTIFICATION, proxyUrl); + const buttonsAndActions: { buttonMessage: string, buttonAction: () => void }[] = [ + { + buttonMessage: translationService.getMessage(BUTTONS.SEE_DETAILS), + buttonAction: vsNotificationService.showOutput + }, + { + buttonMessage: translationService.getMessage(BUTTONS.GIVE_FEEDBACK), + buttonAction: () => { + vscode.commands.executeCommand('suitecloud.opendevassistfeedbackform') + } + }, + ]; + vsNotificationService.showCommandInfoWithSpecificButtonsAndActions(infoMessage, buttonsAndActions); +} + const showStartDevAssistProblemNotification = (errorStage: string, error: string, devAssistStatusBar: vscode.StatusBarItem) => { // console.log(`There was a problem when starting DevAssist service. (${errorStage})\n${error}`) setErrorDevAssistStausBarMessage(devAssistStatusBar); @@ -200,7 +218,7 @@ const showStartDevAssistProblemNotification = (errorStage: string, error: string const errorMessage = translationService.getMessage(DEVASSIST_SERVICE.IS_STOPPED.NOTIFICATION); const buttonsAndActions: { buttonMessage: string, buttonAction: () => void }[] = [ { - buttonMessage: translationService.getMessage(DEVASSIST_SERVICE.SEE_DETAILS_BUTTON), + buttonMessage: translationService.getMessage(BUTTONS.SEE_DETAILS), buttonAction: () => { // show suitecloud output and devassist settings output.show(); @@ -217,7 +235,7 @@ const showDevAssistEmitProblemLog = (errorStage: string, emitError: string, devA const errorMessage = translationService.getMessage(DEVASSIST_SERVICE.IS_STOPPED.NOTIFICATION); const buttonsAndActions: { buttonMessage: string, buttonAction: () => void }[] = [ { - buttonMessage: translationService.getMessage(DEVASSIST_SERVICE.SEE_DETAILS_BUTTON), + buttonMessage: translationService.getMessage(BUTTONS.SEE_DETAILS), buttonAction: () => output.show(), }, ]; @@ -232,7 +250,7 @@ const showDevAssistEmitProblemNotification = (errorStage: string, emitError: str const errorMessage = translationService.getMessage(DEVASSIST_SERVICE.IS_STOPPED.NOTIFICATION); const buttonsAndActions: { buttonMessage: string, buttonAction: () => void }[] = [ { - buttonMessage: translationService.getMessage(DEVASSIST_SERVICE.SEE_DETAILS_BUTTON), + buttonMessage: translationService.getMessage(BUTTONS.SEE_DETAILS), buttonAction: () => { // show suitecloud output and devassist settings output.show() @@ -259,7 +277,7 @@ const updateDevAssistConfigStatus = (): void => { devAssistConfigStatus.previous = previousConfig; } -const getDevAssistCurrentSettings = (): devAssistConfig => { +export const getDevAssistCurrentSettings = (): devAssistConfig => { const devAssistConfigSection = vscode.workspace.getConfiguration(DEVASSIST.CONFIG_KEYS.devAssistSection); // * The *effective* value (returned by {@linkcode WorkspaceConfiguration.get get}) is computed by overriding or merging the values in the following order: diff --git a/packages/vscode-extension/src/suitecloud.ts b/packages/vscode-extension/src/suitecloud.ts index 38403896..d096df88 100644 --- a/packages/vscode-extension/src/suitecloud.ts +++ b/packages/vscode-extension/src/suitecloud.ts @@ -29,6 +29,7 @@ import { VSTranslationService } from './service/VSTranslationService'; import { devAssistConfigurationChangeHandler, startDevAssistProxyIfEnabled } from './startup/DevAssistConfiguration'; import { showSetupAccountWarningMessageIfNeeded } from './startup/ShowSetupAccountWarning'; import { createAuthIDStatusBar, createDevAssistStatusBar, createSuiteCloudProjectStatusBar, updateAuthIDStatusBarIfNeeded, updateStatusBars } from './startup/StatusBarItemsFunctions'; +import { openDevAssistFeedbackForm } from './webviews/FeedbackFormWebview'; const SCLOUD_OUTPUT_CHANNEL_NAME = 'SuiteCloud'; @@ -87,6 +88,13 @@ export async function activate(context: vscode.ExtensionContext) { () => vscode.commands.executeCommand('workbench.action.openWorkspaceSettings', DEVASSIST.CONFIG_KEYS.devAssistSection)) ); + // DevAssist Feedback Form WebView + context.subscriptions.push( + vscode.commands.registerCommand('suitecloud.opendevassistfeedbackform', + () => openDevAssistFeedbackForm(context) + ) + ); + // add watchers needed to update the status bars context.subscriptions.push( vscode.window.onDidChangeActiveTextEditor((textEditor) => updateStatusBars(textEditor, suitecloudProjectStatusBar, authIDStatusBar)), diff --git a/packages/vscode-extension/src/util/ExtensionUtil.ts b/packages/vscode-extension/src/util/ExtensionUtil.ts index b026ee14..6eb81def 100644 --- a/packages/vscode-extension/src/util/ExtensionUtil.ts +++ b/packages/vscode-extension/src/util/ExtensionUtil.ts @@ -65,7 +65,12 @@ export class SuiteCloudAuthProxyService extends SuiteCloudAuthProxyServiceTypedJ export const AccountCredentialsFormatter: { getInfoString(accountCredentials: any): string; } = require('@oracle/suitecloud-cli/src/utils/AccountCredentialsFormatter'); -export const FileUtils = require('@oracle/suitecloud-cli/src/utils/FileUtils'); +export const FileUtils : { + readAsJson(filePath: string) : any; + readAsString(fileName: string) : string; + exists(fileName: string) : boolean; + createDirectory(dirPath: string) : void; +} = require('@oracle/suitecloud-cli/src/utils/FileUtils'); export const InteractiveAnswersValidator: { showValidationResults(value: string, ...funcs: Function[]): string | boolean; diff --git a/packages/vscode-extension/src/webviews/FeedbackFormWebview.ts b/packages/vscode-extension/src/webviews/FeedbackFormWebview.ts new file mode 100644 index 00000000..f40322dd --- /dev/null +++ b/packages/vscode-extension/src/webviews/FeedbackFormWebview.ts @@ -0,0 +1,202 @@ +/* + ** Copyright (c) 2024 Oracle and/or its affiliates. All rights reserved. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. + */ + +import * as vscode from 'vscode'; +import { DEVASSIST } from '../ApplicationConstants'; +import { DEVASSIST_SERVICE } from '../service/TranslationKeys'; +import { MediaFileService } from '../service/MediaFileService'; +import { FEEDBACK_FORM_FILE_NAMES } from '../service/MediaFileKeys'; +import { VSTranslationService } from '../service/VSTranslationService'; +import { getDevAssistCurrentSettings } from '../startup/DevAssistConfiguration'; +import { ApplicationConstants } from '../util/ExtensionUtil'; +import VSConsoleLogger from '../loggers/VSConsoleLogger'; +import { + validateIntegerWithinInterval, + validateMultipleOptionField, + validateTextAreaField, +} from './WebviewFieldValidationUtils'; + +const translationService = new VSTranslationService(); +const vsLogger = new VSConsoleLogger(); + +const PROXY_URL = DEVASSIST.PROXY_URL; + +const FEEDBACK_FORM_EVENTS = { + WEBVIEW_CONTROLLER: { + CLOSE : "CLOSE_WEBVIEW", + SUBMIT_FEEDBACK : "SUBMIT_FEEDBACK", + OPEN_NEW_FEEDBACK_FORM : "OPEN_NEW_FEEDBACK_FORM", + }, + HTML_PAGE: { + RENDER_TOAST_MESSAGE : "RENDER_TOAST_MESSAGE", + } +} + +type WebviewEventMessage = { + eventType: string; + eventData?: FeedbackFormData +}; + +type FeedbackFormData = { + feedback: string; + topics: string[]; + rating: number; +}; + +const VALID_FEEDBACK_TOPICS = [ + "CodeExplanation", + "SDFObjectGeneration", + "SuiteScriptCodeGeneration", + "UnitTesting", + "Other" +] + +let feedbackFormPanel: vscode.WebviewPanel | undefined; +let mediaService: MediaFileService; +export const openDevAssistFeedbackForm = (context: vscode.ExtensionContext) => { + + // if one FeedbackForm is already open, reveal it instead of creating a new one + if (feedbackFormPanel) { + feedbackFormPanel.reveal(); + return; + } + + mediaService = new MediaFileService(context); + feedbackFormPanel = vscode.window.createWebviewPanel( + 'devassistfeedbackform', + 'SuiteCloud Developer Assistant Feedback', + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.file(mediaService.getMediaDirectoryFullPath()), + ], + }, + ); + + // calculate cssUri as a proper Webview File Path and generate HTML content with it + const cssFilePath = mediaService.getMediaFileFullPath(FEEDBACK_FORM_FILE_NAMES.MAIN_PAGE.CSS); + const cssWebviewUri = feedbackFormPanel?.webview.asWebviewUri(vscode.Uri.file(cssFilePath)).toString(); + feedbackFormPanel.webview.html = mediaService.generateHTMLContentFromMediaFile(FEEDBACK_FORM_FILE_NAMES.MAIN_PAGE.HTML, cssWebviewUri); + + // Clean up the reference when the WebviewPanel is closed + feedbackFormPanel.onDidDispose( + () => { + feedbackFormPanel = undefined; + }, + null, + context.subscriptions, + ); + + // Handle messages/events sent from HTML to this Webview controller + feedbackFormPanel.webview.onDidReceiveMessage( + (webviewEvent) => handleWebviewEventMessage(webviewEvent, cssWebviewUri), + undefined, + context.subscriptions, + ); +}; + +const handleWebviewEventMessage = async (webviewEvent : WebviewEventMessage, cssWebviewUri : string) : Promise => { + switch (webviewEvent.eventType) { + case FEEDBACK_FORM_EVENTS.WEBVIEW_CONTROLLER.SUBMIT_FEEDBACK: + await handleSubmitFeedbackFormEvent(webviewEvent.eventData!, cssWebviewUri); + break; + + case FEEDBACK_FORM_EVENTS.WEBVIEW_CONTROLLER.OPEN_NEW_FEEDBACK_FORM: + feedbackFormPanel!.webview.html = mediaService.generateHTMLContentFromMediaFile(FEEDBACK_FORM_FILE_NAMES.MAIN_PAGE.HTML, cssWebviewUri); + break; + + case FEEDBACK_FORM_EVENTS.WEBVIEW_CONTROLLER.CLOSE: + feedbackFormPanel?.dispose(); + break; + } +} + +const handleSubmitFeedbackFormEvent = async (formData : FeedbackFormData, cssWebviewUri : string) => { + + // validate Feedback Form Data + const validationResult = validateFormData(formData); + if (typeof validationResult === 'string') { + await sendErrorEventToWebview(translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.GENERIC_VALIDATION_ERROR_WRAPPER, validationResult)); + return; + } + + feedbackFormPanel!.webview.html = mediaService.generateHTMLContentFromMediaFile(FEEDBACK_FORM_FILE_NAMES.SUBMITTING_HTML, cssWebviewUri); + + // Send request to NetSuite Backend through Proxy + try { + const currentProxySettings = getDevAssistCurrentSettings(); + const requestBody = JSON.stringify(formData); + const response = await fetch(`${PROXY_URL.SCHEME}${PROXY_URL.LOCALHOST_IP}:${currentProxySettings.localPort}${PROXY_URL.FEEDBACK_PATH}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: requestBody + }); + + if (response.ok) { + vsLogger.printTimestamp(); + vsLogger.info("Feedback Form Success: " + response.status + ' ' + response.statusText); + vsLogger.info(''); + feedbackFormPanel!.webview.html = mediaService.generateHTMLContentFromMediaFile(FEEDBACK_FORM_FILE_NAMES.SUCCESS_HTML, cssWebviewUri); + } + else { + vsLogger.printTimestamp(); + vsLogger.error("Feedback Form External Failure: " + response.status + ' ' + response.statusText); + vsLogger.error(''); + + // "Manual reauthentication is needed" proxy event + if (response.status === ApplicationConstants.HTTP_RESPONSE_CODE.FORBIDDEN) { + const responseBody : any = await response.json(); + feedbackFormPanel!.webview.html = mediaService.generateHTMLContentFromMediaFile(FEEDBACK_FORM_FILE_NAMES.MAIN_PAGE.HTML, cssWebviewUri); + await sendErrorEventToWebview(`Error 403: "${responseBody.error}"`); + } + else { + feedbackFormPanel!.webview.html = mediaService.generateHTMLContentFromMediaFile(FEEDBACK_FORM_FILE_NAMES.FAILURE_HTML, cssWebviewUri); + } + } + } catch (e) { + vsLogger.printTimestamp(); + vsLogger.error("Feedback Form Internal Failure: " + e); + vsLogger.error(''); + + // TODO: Find a way to not delete the user input when swaping HTML / clicking out + // -> https://code.visualstudio.com/api/extension-guides/webview#getstate-and-setstate + feedbackFormPanel!.webview.html = mediaService.generateHTMLContentFromMediaFile(FEEDBACK_FORM_FILE_NAMES.MAIN_PAGE.HTML, cssWebviewUri); + await sendErrorEventToWebview(translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.SUBMITTING_ERROR)); + } +} + + +const sendErrorEventToWebview = async (message: string) => { + await feedbackFormPanel!.webview.postMessage({ + eventType: FEEDBACK_FORM_EVENTS.HTML_PAGE.RENDER_TOAST_MESSAGE, + eventData: { + toastMessageLevel : 'error', + toastMessageContent: message} + }); +} + +const validateFormData = (formData : FeedbackFormData) => { + + // validate feedback field (textArea) + let validationResult = validateTextAreaField("Your feedback (text area)", formData.feedback, 1000); + if (typeof validationResult === 'string') { + return validationResult; + } + + // validate selectedTopic field + validationResult = validateMultipleOptionField("Your feedback (topic multi-choice selector)", formData.topics, VALID_FEEDBACK_TOPICS); + if (typeof validationResult === 'string') { + return validationResult; + } + + // validate rating field (integer 0 < x <= 5) + validationResult = validateIntegerWithinInterval("Rating", formData.rating, 1, 5); + if (typeof validationResult === 'string') { + return validationResult; + } + + return true; +} \ No newline at end of file diff --git a/packages/vscode-extension/src/webviews/WebviewFieldValidationUtils.ts b/packages/vscode-extension/src/webviews/WebviewFieldValidationUtils.ts new file mode 100644 index 00000000..0e8909b5 --- /dev/null +++ b/packages/vscode-extension/src/webviews/WebviewFieldValidationUtils.ts @@ -0,0 +1,65 @@ +/* + ** Copyright (c) 2024 Oracle and/or its affiliates. All rights reserved. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. + */ + +import { DEVASSIST_SERVICE } from '../service/TranslationKeys'; +import { VSTranslationService } from '../service/VSTranslationService'; + +const translationService = new VSTranslationService(); + +export const validateTextAreaField = (fieldName : string, textContent : string, maxLength : number) => { + if (!textContent || textContent.trim().length === 0) + { + return translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.VALIDATION_ERROR, + fieldName, + translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.CANNOT_BE_EMPTY_TEXT)); + } + else if (textContent.length > maxLength) { + return translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.VALIDATION_ERROR, + fieldName, + translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.CANNOT_BE_TOO_LONG, maxLength.toString())); + } + + return true; +} + +export const validateMultipleOptionField = (fieldName : string, selectedOptions : string[], acceptableOptions : string[]) => { + if (!selectedOptions || selectedOptions.length === 0) { + return translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.VALIDATION_ERROR, + fieldName, + translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.CANNOT_BE_EMPTY_MULTIPLE_OPTION)); + } + else { + const uniqueSelectedOptions = new Set(selectedOptions); + if (uniqueSelectedOptions.size !== selectedOptions.length) { + return translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.VALIDATION_ERROR, + fieldName, + translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.CANNOT_HAVE_REPEATED_VALUES, selectedOptions.toString())); + } + for (const option of selectedOptions) { + if (acceptableOptions.includes(option)) { + return translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.VALIDATION_ERROR, + fieldName, + translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.MUST_HAVE_SPECIFIC_VALUES, acceptableOptions.toString())); + } + } + } + + return true; +} + +export const validateIntegerWithinInterval = (fieldName : string, nValue : number, lowerBound : number, upperBound : number) => { + if (!nValue || nValue === 0) { + return translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.VALIDATION_ERROR, + fieldName, + translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.CANNOT_BE_EMPTY_NUMERIC)); + } + else if (!Number.isInteger(nValue) || nValue < lowerBound || nValue > upperBound) { + return translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.VALIDATION_ERROR, + fieldName, + translationService.getMessage(DEVASSIST_SERVICE.FEEDBACK_FORM.FIELD.MUST_HAVE_SPECIFIC_VALUES, lowerBound.toString(), upperBound.toString())); + } + + return true; +} \ No newline at end of file