/** * Copyright (c) 2020-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { useState, useRef, useEffect, useMemo } from 'react'; import { observer } from 'mobx-react-lite'; import { MINIMUM_SERVICE_OWNERS, ServiceEditorState, SERVICE_TAB, OWNERSHIP_OPTIONS, type ServiceOwnerOption, } from '../../../../stores/editor/editor-state/element-editor-state/service/ServiceEditorState.js'; import { clsx, PencilIcon, LockIcon, TimesIcon, ErrorIcon, ErrorWarnIcon, PanelFormBooleanField, PanelForm, CustomSelectorInput, PanelFormValidatedTextField, PanelContentLists, SparkleIcon, } from '@finos/legend-art'; import { assertErrorThrown, debounce, HttpStatus, NetworkClientError, prettyCONSTName, } from '@finos/legend-shared'; import { ServiceExecutionEditor } from './ServiceExecutionEditor.js'; import { LEGEND_STUDIO_TEST_ID } from '../../../../__lib__/LegendStudioTesting.js'; import { ServiceRegistrationEditor } from './ServiceRegistrationEditor.js'; import { useEditorStore } from '../../EditorStoreProvider.js'; import { service_addOwner, service_deleteOwner, service_removePatternParameter, service_setAutoActivateUpdates, service_setDocumentation, service_setPattern, service_setMcpServer, service_setTitle, service_updateOwner, service_deploymentOwnership, service_addUserOwnership, service_updateUserOwnership, service_deleteValueFromUserOwnership, } from '../../../../stores/graph-modifier/DSL_Service_GraphModifierHelper.js'; import { useApplicationNavigationContext, useApplicationStore, } from '@finos/legend-application'; import { validate_ServicePattern, validate_ServiceMcpServer, DeploymentOwnership, Service, UserListOwnership, } from '@finos/legend-graph'; import { LEGEND_STUDIO_APPLICATION_NAVIGATION_CONTEXT_KEY } from '../../../../__lib__/LegendStudioApplicationNavigationContext.js'; import { ServiceTestableEditor } from './testable/ServiceTestableEditor.js'; import { flowResult } from 'mobx'; import { ServicePostValidationsEditor } from './ServicePostValidationEditor.js'; import type { DSL_Service_LegendStudioApplicationPlugin_Extension } from '../../../../stores/extensions/DSL_Service_LegendStudioApplicationPlugin_Extension.js'; import { LEGENDAI_SUGGEST_STAGE, LEGENDAI_SUGGEST_SURFACE, } from '../../../../__lib__/LegendStudioTelemetryHelper.js'; import { useLegendAISuggestTelemetry } from '../LegendAISuggestTelemetryHooks.js'; import { LEGEND_STUDIO_DOCUMENTATION_KEY } from '../../../../__lib__/LegendStudioDocumentation.js'; import { DocumentationLink } from '@finos/legend-lego/application'; type UserOption = { label: string; value: string }; const ServiceGeneralEditor = observer(() => { const editorStore = useEditorStore(); const serviceState = editorStore.tabManagerState.getCurrentEditorState(ServiceEditorState); const service = serviceState.service; const ownership = service.ownership; const isReadOnly = serviceState.isReadOnly; // Pattern const patternRef = useRef(null); const [pattern, setPattern] = useState(service.pattern); const updatePattern = (newPattern: string): void => { if (!isReadOnly) { service_setPattern(service, newPattern); } }; const getPatternValidationMessage = ( inputPattern: string, ): string | undefined => { const patternValidationResult = validate_ServicePattern(inputPattern); return patternValidationResult ? patternValidationResult.messages[0] : undefined; }; const removePatternParameter = (val: string): (() => void) => (): void => { service_removePatternParameter(service, val); setPattern(service.pattern); }; //McpServer const [mcpServer, setMcpServer] = useState(service.mcpServer); const updateMcpServer = (newMcpServer: string | undefined): void => { if (!isReadOnly) { service_setMcpServer(service, newMcpServer); setMcpServer(service.mcpServer); } }; const getMcpServerValidationMessage = ( inputMcpServer: string | undefined, ): string | undefined => { const mcpServerValidationResult = validate_ServiceMcpServer(inputMcpServer); return mcpServerValidationResult ? mcpServerValidationResult.messages[0] : undefined; }; // Owners const owners = service.owners; const [showOwnerEditInput, setShowOwnerEditInput] = useState< boolean | number >(false); const applicationStore = useApplicationStore(); const [ownerInputValue, setOwnerInputValue] = useState(''); const [ownerInputs, setOwnerInputs] = useState([]); const [searchText, setSearchText] = useState(''); const [userOptions, setUserOptions] = useState([]); const [isLoadingUsers, setIsLoadingUsers] = useState(false); const showAddOwnerInput = (): void => setShowOwnerEditInput(true); const showEditOwnerInput = (value: string, idx: number): (() => void) => (): void => { setOwnerInputValue(value); setShowOwnerEditInput(idx); }; const hideAddOrEditOwnerInput = (): void => { setShowOwnerEditInput(false); setOwnerInputValue(''); }; const changeOwnerInputValue: React.ChangeEventHandler = ( event, ) => setOwnerInputValue(event.target.value); const addOwner = (): void => { ownerInputs.forEach((value) => { if (value && !isReadOnly && !owners.includes(value)) { service_addOwner(service, value); } }); hideAddOrEditOwnerInput(); }; const updateOwner = (idx: number): (() => void) => (): void => { if (ownerInputValue && !isReadOnly && !owners.includes(ownerInputValue)) { service_updateOwner(service, ownerInputValue, idx); } }; const deleteOwner = (idx: number): (() => void) => (): void => { if (!isReadOnly) { service_deleteOwner(service, idx); // Since we keep track of the value currently being edited using the index, we have to account for it as we delete entry if ( typeof showOwnerEditInput === 'number' && showOwnerEditInput > idx ) { setShowOwnerEditInput(showOwnerEditInput - 1); } } }; const changeUserOwnerInputValue: React.ChangeEventHandler< HTMLInputElement > = (event) => setOwnerInputValue(event.target.value); const updateDeploymentIdentifier: React.ChangeEventHandler< HTMLInputElement > = (event) => { if (!isReadOnly && ownership instanceof DeploymentOwnership) { service_deploymentOwnership(ownership, event.target.value); } }; const addUser = (): void => { ownerInputs.forEach((value) => { if ( value && !isReadOnly && ownership instanceof UserListOwnership && !ownership.users.includes(value) ) { service_addUserOwnership(ownership, value); } }); hideAddOrEditOwnerInput(); }; const updateUser = (idx: number): (() => void) => (): void => { if ( ownerInputValue && !isReadOnly && ownership instanceof UserListOwnership && !ownership.users.includes(ownerInputValue) ) { service_updateUserOwnership(ownership, ownerInputValue, idx); } }; const deleteUser = (idx: number): (() => void) => (): void => { if (!isReadOnly && ownership instanceof UserListOwnership) { service_deleteValueFromUserOwnership(ownership, idx); // Since we keep track of the value currently being edited using the index, we have to account for it as we delete entry if ( typeof showOwnerEditInput === 'number' && showOwnerEditInput > idx ) { setShowOwnerEditInput(showOwnerEditInput - 1); } } }; // Other const changeDocumentation: React.ChangeEventHandler = ( event, ) => { if (!isReadOnly) { service_setDocumentation(service, event.target.value); } }; // AI documentation suggestion const legendAIUrl = editorStore.applicationStore.config.legendAIUrl; const aiDocSuggester = legendAIUrl ? editorStore.pluginManager .getApplicationPlugins() .map((p) => ( p as DSL_Service_LegendStudioApplicationPlugin_Extension ).getExtraServiceDocumentationAISuggester?.(), ) .find(Boolean) : undefined; const [isSuggestingWithAI, setIsSuggestingWithAI] = useState(false); const [aiDocSuggestion, setAIDocSuggestion] = useState( undefined, ); const aiSuggestTelemetry = useLegendAISuggestTelemetry( editorStore, { surface: LEGENDAI_SUGGEST_SURFACE.SERVICE, elementPath: service.path }, { legendAIUrl, available: Boolean(aiDocSuggester), isReadOnly }, ); const suggestDocumentationWithAI = async (): Promise => { if (!aiDocSuggester || !legendAIUrl) { return; } const request = aiSuggestTelemetry.launch({ existingText: service.documentation, }); setIsSuggestingWithAI(true); setAIDocSuggestion(undefined); let stage = LEGENDAI_SUGGEST_STAGE.SERIALIZE; try { const serviceGrammar = await editorStore.graphManagerState.graphManager.elementsToPureCode([ service, ]); stage = LEGENDAI_SUGGEST_STAGE.REQUEST; const suggestion = await aiDocSuggester(serviceGrammar, legendAIUrl); const shouldShow = aiSuggestTelemetry.succeed(request, { suggestionText: suggestion, definitionsLength: serviceGrammar.length, currentText: service.documentation, }); if (!shouldShow) { applicationStore.notificationService.notifyWarning( 'LegendAI did not return a documentation suggestion for this service', ); } setAIDocSuggestion(shouldShow ? suggestion : undefined); } catch (error) { assertErrorThrown(error); aiSuggestTelemetry.fail(request, error, stage); if ( error instanceof NetworkClientError && (error.response.status === HttpStatus.UNAUTHORIZED || error.response.status === HttpStatus.FORBIDDEN) ) { const docEntry = applicationStore.documentationService.getDocEntry( LEGEND_STUDIO_DOCUMENTATION_KEY.LEGENDAI_HOW_TO_GET_ENTITLEMENTS, ); if (docEntry?.url) { error.message = `${error.message}. Please check how to get entitlements: ${docEntry.url}`; } } throw error; } finally { setIsSuggestingWithAI(false); } }; const applyAIDocSuggestion = (): void => { if (!aiDocSuggestion) { return; } const servicePath = service.path; aiSuggestTelemetry.apply({ existingText: service.documentation, appliedText: aiDocSuggestion, readCurrentText: (store) => { const element = store.graphManagerState.graph.getNullableElement(servicePath); return element instanceof Service ? { found: true, text: element.documentation } : { found: false }; }, }); service_setDocumentation(service, aiDocSuggestion); setAIDocSuggestion(undefined); }; const discardAIDocSuggestion = (): void => { aiSuggestTelemetry.discard(); setAIDocSuggestion(undefined); }; const changeTitle: React.ChangeEventHandler = (event) => { if (!isReadOnly) { service_setTitle(service, event.target.value); } }; const toggleAutoActivateUpdates = (): void => { service_setAutoActivateUpdates(service, !service.autoActivateUpdates); }; const debouncedSearchUsers = useMemo( () => debounce((input: string): void => { setIsLoadingUsers(true); flowResult(serviceState.searchUsers(input)) .then((users) => setUserOptions( users.map((u) => ({ value: u.userId, label: u.userId, })), ), ) .then(() => setIsLoadingUsers(false)) .catch(serviceState.editorStore.applicationStore.alertUnhandledError); }, 500), [serviceState], ); const onSearchTextChange = (value: string): void => { if (value !== searchText) { setSearchText(value); debouncedSearchUsers.cancel(); if (value.length >= 3) { debouncedSearchUsers(value); } else if (value.length === 0) { setUserOptions([]); setIsLoadingUsers(false); } } }; const onUserOptionChange = (options: UserOption[]): void => { setOwnerInputs(options.map((op) => op.label)); setUserOptions([]); debouncedSearchUsers.cancel(); setIsLoadingUsers(false); }; const onOwnershipChange = (val: ServiceOwnerOption | undefined): void => { if (val) { serviceState.setSelectedOwnership(val); } }; useEffect(() => { patternRef.current?.focus(); }, [serviceState]); return (
Title
{!service.title && } {`Provide a title for the service`}
Specifies the URL pattern of the service (e.g. /myService/ {`{param}`} ) } update={(value: string | undefined): void => { updatePattern(value ?? ''); }} validate={getPatternValidationMessage} value={pattern} />
Parameters
URL parameters (each must be surrounded by curly braces) will be passed as arguments for the execution query. Note that if the service is configured to use multi-execution, one of the URL parameters must be chosen as the execution key.
{!service.patternParameters.length && (
No parameter
)} {Boolean(service.patternParameters.length) && service.patternParameters.map((parameter) => (
{parameter}
))}
{/* TODO: potentially we can have a button to go to the service */}
Documentation {aiDocSuggestion && ( AI Suggestion )} {aiDocSuggester && !aiDocSuggestion && ( )}
{!service.documentation && !aiDocSuggestion && ( )} {`Provide a brief description of the service's functionalities and usage`}