diff --git a/components-api/src/main/scala/pl/touk/nussknacker/engine/api/context/ProcessCompilationError.scala b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/context/ProcessCompilationError.scala index 95c0671c722..d4d544bd48b 100644 --- a/components-api/src/main/scala/pl/touk/nussknacker/engine/api/context/ProcessCompilationError.scala +++ b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/context/ProcessCompilationError.scala @@ -316,7 +316,7 @@ object ProcessCompilationError { extends PartSubGraphCompilationError with InASingleNode - final case class RequireValueFromEmptyFixedList(paramName: ParameterName, nodeIds: Set[String]) + final case class EmptyFixedListForRequiredField(paramName: ParameterName, nodeIds: Set[String]) extends PartSubGraphCompilationError final case class InitialValueNotPresentInPossibleValues(paramName: ParameterName, nodeIds: Set[String]) diff --git a/components-api/src/main/scala/pl/touk/nussknacker/engine/api/json/FromJsonDecoder.scala b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/json/FromJsonDecoder.scala new file mode 100644 index 00000000000..587b4db9614 --- /dev/null +++ b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/json/FromJsonDecoder.scala @@ -0,0 +1,25 @@ +package pl.touk.nussknacker.engine.api.json + +import io.circe.Json +import pl.touk.nussknacker.engine.util.Implicits._ + +import scala.jdk.CollectionConverters._ + +object FromJsonDecoder { + + def jsonToAny(json: Json): Any = json.fold( + jsonNull = null, + jsonBoolean = identity[Boolean], + jsonNumber = jsonNumber => + // we pick the narrowest type as possible to reduce the amount of memory and computations overheads + jsonNumber.toInt orElse + jsonNumber.toLong orElse + // We prefer java big decimal over float/double + jsonNumber.toBigDecimal.map(_.bigDecimal) + getOrElse (throw new IllegalArgumentException(s"Not supported json number: $jsonNumber")), + jsonString = identity[String], + jsonArray = _.map(jsonToAny).asJava, + jsonObject = _.toMap.mapValuesNow(jsonToAny).asJava + ) + +} diff --git a/components-api/src/main/scala/pl/touk/nussknacker/engine/api/typed/ValueDecoder.scala b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/typed/ValueDecoder.scala index 54ff028d39d..045039ab206 100644 --- a/components-api/src/main/scala/pl/touk/nussknacker/engine/api/typed/ValueDecoder.scala +++ b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/typed/ValueDecoder.scala @@ -2,6 +2,7 @@ package pl.touk.nussknacker.engine.api.typed import cats.implicits.toTraverseOps import io.circe.{ACursor, Decoder, DecodingFailure, Json} +import pl.touk.nussknacker.engine.api.json.FromJsonDecoder import pl.touk.nussknacker.engine.api.typed.typing._ import java.math.BigInteger @@ -58,19 +59,15 @@ object ValueDecoder { case record: TypedObjectTypingResult => for { fieldsJson <- obj.as[Map[String, Json]] - decodedFields <- record.fields.toList.traverse { case (fieldName, fieldType) => - fieldsJson.get(fieldName) match { - case Some(fieldJson) => decodeValue(fieldType, fieldJson.hcursor).map(fieldName -> _) - case None => - Left( - DecodingFailure( - s"Record field '$fieldName' isn't present in encoded Record fields: $fieldsJson", - List() - ) - ) + decodedFields <- + fieldsJson.toList.traverse { case (fieldName, fieldJson) => + val fieldType = record.fields.getOrElse(fieldName, Unknown) + decodeValue(fieldType, fieldJson.hcursor).map(fieldName -> _) } - } } yield decodedFields.toMap.asJava + case Unknown => + /// For Unknown we fallback to generic json to any conversion. It won't work for some types such as LocalDate but for others should work correctly + obj.as[Json].map(FromJsonDecoder.jsonToAny) case typ => Left(DecodingFailure(s"Decoding of type [$typ] is not supported.", List())) } diff --git a/components-api/src/test/scala/pl/touk/nussknacker/engine/api/json/FromJsonDecoderTest.scala b/components-api/src/test/scala/pl/touk/nussknacker/engine/api/json/FromJsonDecoderTest.scala new file mode 100644 index 00000000000..c27a2baf320 --- /dev/null +++ b/components-api/src/test/scala/pl/touk/nussknacker/engine/api/json/FromJsonDecoderTest.scala @@ -0,0 +1,21 @@ +package pl.touk.nussknacker.engine.api.json + +import io.circe.Json +import org.scalatest.OptionValues +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers + +class FromJsonDecoderTest extends AnyFunSuiteLike with Matchers with OptionValues { + + test("json number decoding pick the narrowest type") { + FromJsonDecoder.jsonToAny(Json.fromInt(1)) shouldBe 1 + FromJsonDecoder.jsonToAny(Json.fromInt(Integer.MAX_VALUE)) shouldBe Integer.MAX_VALUE + FromJsonDecoder.jsonToAny(Json.fromLong(Long.MaxValue)) shouldBe Long.MaxValue + FromJsonDecoder.jsonToAny( + Json.fromBigDecimal(java.math.BigDecimal.valueOf(Double.MaxValue)) + ) shouldBe java.math.BigDecimal.valueOf(Double.MaxValue) + val moreThanLongMaxValue = BigDecimal(Long.MaxValue) * 10 + FromJsonDecoder.jsonToAny(Json.fromBigDecimal(moreThanLongMaxValue)) shouldBe moreThanLongMaxValue.bigDecimal + } + +} diff --git a/components-api/src/test/scala/pl/touk/nussknacker/engine/api/typed/TypingResultDecoderSpec.scala b/components-api/src/test/scala/pl/touk/nussknacker/engine/api/typed/TypingResultDecoderSpec.scala index 2358f5d6df4..b9cd3c45767 100644 --- a/components-api/src/test/scala/pl/touk/nussknacker/engine/api/typed/TypingResultDecoderSpec.scala +++ b/components-api/src/test/scala/pl/touk/nussknacker/engine/api/typed/TypingResultDecoderSpec.scala @@ -56,7 +56,20 @@ class TypingResultDecoderSpec Map("a" -> TypedObjectWithValue(Typed.typedClass[Int], 1)) ), List(Map("a" -> 1).asJava).asJava - ) + ), + typedListWithElementValues( + Typed.record( + List( + "a" -> Typed.typedClass[Int], + "b" -> Typed.typedClass[Int] + ) + ), + List(Map("a" -> 1).asJava, Map("b" -> 2).asJava).asJava + ), + typedListWithElementValues( + Unknown, + List(Map("a" -> 1).asJava, 2).asJava + ), ).foreach { typing => val encoded = TypeEncoders.typingResultEncoder(typing) diff --git a/components-api/src/test/scala/pl/touk/nussknacker/engine/api/typed/ValueDecoderSpec.scala b/components-api/src/test/scala/pl/touk/nussknacker/engine/api/typed/ValueDecoderSpec.scala index 5c7a4205012..9cbb14ab760 100644 --- a/components-api/src/test/scala/pl/touk/nussknacker/engine/api/typed/ValueDecoderSpec.scala +++ b/components-api/src/test/scala/pl/touk/nussknacker/engine/api/typed/ValueDecoderSpec.scala @@ -33,7 +33,7 @@ class ValueDecoderSpec extends AnyFunSuite with EitherValuesDetailedMessage with ) } - test("decodeValue should fail when a required Record field is missing") { + test("decodeValue should ignore missing Record field") { val typedRecord = Typed.record( Map( "name" -> Typed.fromInstance("Alice"), @@ -45,12 +45,10 @@ class ValueDecoderSpec extends AnyFunSuite with EitherValuesDetailedMessage with "name" -> "Alice".asJson ) - ValueDecoder.decodeValue(typedRecord, json.hcursor).leftValue.message should include( - "Record field 'age' isn't present in encoded Record fields" - ) + ValueDecoder.decodeValue(typedRecord, json.hcursor).rightValue shouldBe Map("name" -> "Alice").asJava } - test("decodeValue should not include extra fields that aren't typed") { + test("decodeValue should decode extra fields using generic json decoding strategy") { val typedRecord = Typed.record( Map( "name" -> Typed.fromInstance("Alice"), @@ -66,8 +64,9 @@ class ValueDecoderSpec extends AnyFunSuite with EitherValuesDetailedMessage with ValueDecoder.decodeValue(typedRecord, json.hcursor) shouldEqual Right( Map( - "name" -> "Alice", - "age" -> 30 + "name" -> "Alice", + "age" -> 30, + "occupation" -> "nurse" ).asJava ) } diff --git a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/extractor/HandleResponse.scala b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/extractor/HandleResponse.scala index 0d95933449b..f0f0257c6df 100644 --- a/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/extractor/HandleResponse.scala +++ b/components/openapi/src/main/scala/pl/touk/nussknacker/openapi/extractor/HandleResponse.scala @@ -2,14 +2,14 @@ package pl.touk.nussknacker.openapi.extractor import java.util.Collections import io.circe.Json -import pl.touk.nussknacker.engine.json.swagger.extractor.FromJsonDecoder +import pl.touk.nussknacker.engine.json.swagger.decode.FromJsonSchemaBasedDecoder import pl.touk.nussknacker.engine.json.swagger.{SwaggerArray, SwaggerTyped} object HandleResponse { def apply(res: Option[Json], responseType: SwaggerTyped): AnyRef = res match { case Some(json) => - FromJsonDecoder.decode(json, responseType) + FromJsonSchemaBasedDecoder.decode(json, responseType) case None => responseType match { case _: SwaggerArray => Collections.EMPTY_LIST diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Expression suggester should display colorfull and sorted completions #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Expression suggester should display colorfull and sorted completions #0.png index dc9a0bfbbee..f5f7253c8a9 100644 Binary files a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Expression suggester should display colorfull and sorted completions #0.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Expression suggester should display colorfull and sorted completions #0.png differ diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Expression suggester should display colorfull and sorted completions #1.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Expression suggester should display colorfull and sorted completions #1.png index 061e72f6285..ff3f1d07d54 100644 Binary files a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Expression suggester should display colorfull and sorted completions #1.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Expression suggester should display colorfull and sorted completions #1.png differ diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should allow adding input parameters and display used fragment graph in modal #7.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should allow adding input parameters and display used fragment graph in modal #7.png index 7606c4988e8..0699447c130 100644 Binary files a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should allow adding input parameters and display used fragment graph in modal #7.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should allow adding input parameters and display used fragment graph in modal #7.png differ diff --git a/designer/client/cypress/e2e/fragment.cy.ts b/designer/client/cypress/e2e/fragment.cy.ts index c31cfcd6b48..7545c06fcf9 100644 --- a/designer/client/cypress/e2e/fragment.cy.ts +++ b/designer/client/cypress/e2e/fragment.cy.ts @@ -66,7 +66,7 @@ describe("Fragment", () => { cy.get("[data-testid='settings:4']").contains("Typing...").should("not.exist"); cy.get("[data-testid='settings:4']").find("[id='ace-editor']").type("{enter}"); cy.get("[data-testid='settings:4']") - .contains(/Add list item/i) + .contains(/Suggested values/i) .siblings() .eq(0) .find("[data-testid='form-helper-text']") diff --git a/designer/client/cypress/e2e/labels.cy.ts b/designer/client/cypress/e2e/labels.cy.ts index d3e882c720c..a65a7db60cd 100644 --- a/designer/client/cypress/e2e/labels.cy.ts +++ b/designer/client/cypress/e2e/labels.cy.ts @@ -33,6 +33,14 @@ describe("Scenario labels", () => { cy.get("[data-testid=scenario-label-0]").should("be.visible").contains("tagX"); + cy.get("@labelInput").should("be.visible").click().type("tagX"); + + cy.wait("@labelvalidation"); + + cy.get("@labelInput").should("be.visible").contains("This label already exists. Please enter a unique value."); + + cy.get("@labelInput").find("input").clear(); + cy.get("@labelInput").should("be.visible").click().type("tag2"); cy.wait("@labelvalidation"); diff --git a/designer/client/cypress/e2e/process.cy.ts b/designer/client/cypress/e2e/process.cy.ts index afcd1748916..44f32c2a0ce 100644 --- a/designer/client/cypress/e2e/process.cy.ts +++ b/designer/client/cypress/e2e/process.cy.ts @@ -333,7 +333,7 @@ describe("Process", () => { cy.layoutScenario(); cy.contains("button", "ad hoc").should("be.enabled").click(); - cy.get("[data-testid=window]").should("be.visible").find("input").type("10"); //There should be only one input field + cy.get("[data-testid=window]").should("be.visible").find("#ace-editor").type("10"); cy.get("[data-testid=window]") .contains(/^test$/i) .should("be.enabled") diff --git a/designer/client/src/components/AddProcessDialog.tsx b/designer/client/src/components/AddProcessDialog.tsx index 060ad6a7a51..f947318e4eb 100644 --- a/designer/client/src/components/AddProcessDialog.tsx +++ b/designer/client/src/components/AddProcessDialog.tsx @@ -1,9 +1,9 @@ import { WindowButtonProps, WindowContentProps } from "@touk/window-manager"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { visualizationUrl } from "../common/VisualizationUrl"; import { useProcessNameValidators } from "../containers/hooks/useProcessNameValidators"; -import HttpService, { ProcessingMode, ScenarioParametersCombination } from "../http/HttpService"; +import HttpService, { ProcessingMode } from "../http/HttpService"; import { WindowContent } from "../windowManager"; import { AddProcessForm, FormValue, TouchedValue } from "./AddProcessForm"; import { extendErrors, mandatoryValueValidator } from "./graph/node-modal/editors/Validators"; @@ -12,6 +12,7 @@ import { NodeValidationError } from "../types"; import { flow, isEmpty, transform } from "lodash"; import { useProcessFormDataOptions } from "./useProcessFormDataOptions"; import { LoadingButtonTypes } from "../windowManager/LoadingButton"; +import { useGetAllCombinations } from "./useGetAllCombinations"; interface AddProcessDialogProps extends WindowContentProps { isFragment?: boolean; @@ -22,7 +23,12 @@ export function AddProcessDialog(props: AddProcessDialogProps): JSX.Element { const { t } = useTranslation(); const { isFragment = false, errors = [], ...passProps } = props; const nameValidators = useProcessNameValidators(); - const [value, setState] = useState({ processName: "", processCategory: "", processingMode: "", processEngine: "" }); + const [value, setState] = useState({ + processName: "", + processCategory: "", + processingMode: "" as ProcessingMode, + processEngine: "", + }); const [touched, setTouched] = useState({ processName: false, processCategory: false, @@ -30,8 +36,13 @@ export function AddProcessDialog(props: AddProcessDialogProps): JSX.Element { processEngine: false, }); const [processNameFromBackend, setProcessNameFromBackendError] = useState([]); - const [engineSetupErrors, setEngineSetupErrors] = useState>({}); - const [allCombinations, setAllCombinations] = useState([]); + + const { engineSetupErrors, allCombinations } = useGetAllCombinations({ + processCategory: value.processCategory, + processingMode: value.processingMode, + processEngine: value.processEngine, + }); + const engineErrors: NodeValidationError[] = (engineSetupErrors[value.processEngine] ?? []).map((error) => ({ fieldName: "processEngine", errorType: "SaveNotAllowed", @@ -122,12 +133,6 @@ export function AddProcessDialog(props: AddProcessDialogProps): JSX.Element { setTouched(touched); }; - useEffect(() => { - HttpService.fetchScenarioParametersCombinations().then((response) => { - setAllCombinations(response.data.combinations); - setEngineSetupErrors(response.data.engineSetupErrors); - }); - }, []); return ( ; diff --git a/designer/client/src/components/graph/node-modal/ParametersUtils.ts b/designer/client/src/components/graph/node-modal/ParametersUtils.ts index 5748225133c..5acfa3dac5f 100644 --- a/designer/client/src/components/graph/node-modal/ParametersUtils.ts +++ b/designer/client/src/components/graph/node-modal/ParametersUtils.ts @@ -34,7 +34,7 @@ const parametersPath = (node) => { export function adjustParameters(node: NodeType, parameterDefinitions: UIParameter[]): AdjustReturn { const path = parametersPath(node); - if (!path) { + if (!path || !parameterDefinitions) { return { adjustedNode: node, unusedParameters: [] }; } @@ -42,7 +42,7 @@ export function adjustParameters(node: NodeType, parameterDefinitions: UIParamet const currentParameters = get(currentNode, path); //TODO: currently dynamic branch parameters are *not* supported... const adjustedParameters = parameterDefinitions - ?.filter((def) => !def.branchParam) + .filter((def) => !def.branchParam) .map((def) => { const currentParam = currentParameters.find((p) => p.name == def.name); const parameterFromDefinition = { diff --git a/designer/client/src/components/graph/node-modal/editors/expression/AceWrapper.tsx b/designer/client/src/components/graph/node-modal/editors/expression/AceWrapper.tsx index 965c87c9b86..037134a079a 100644 --- a/designer/client/src/components/graph/node-modal/editors/expression/AceWrapper.tsx +++ b/designer/client/src/components/graph/node-modal/editors/expression/AceWrapper.tsx @@ -25,18 +25,18 @@ export interface AceWrapperProps extends Pick, ): JSX.Element { const { language, readOnly, rows = 1, editorMode } = inputProps; @@ -183,9 +191,10 @@ export default forwardRef(function AceWrapper( className={readOnly ? " read-only" : ""} wrapEnabled={!!wrapEnabled} showGutter={!!showLineNumbers} - editorProps={DEFAULF_EDITOR_PROPS} + editorProps={DEFAULT_EDITOR_PROPS} setOptions={{ ...DEFAULT_OPTIONS, + enableLiveAutocompletion, showLineNumbers, }} enableBasicAutocompletion={customAceEditorCompleter && [customAceEditorCompleter]} diff --git a/designer/client/src/components/graph/node-modal/editors/expression/CustomCompleterAceEditor.tsx b/designer/client/src/components/graph/node-modal/editors/expression/CustomCompleterAceEditor.tsx index 3b37693fa41..d6682b0427f 100644 --- a/designer/client/src/components/graph/node-modal/editors/expression/CustomCompleterAceEditor.tsx +++ b/designer/client/src/components/graph/node-modal/editors/expression/CustomCompleterAceEditor.tsx @@ -28,10 +28,11 @@ export type CustomCompleterAceEditorProps = { showValidation?: boolean; isMarked?: boolean; className?: string; + enableLiveAutocompletion?: boolean; }; export function CustomCompleterAceEditor(props: CustomCompleterAceEditorProps): JSX.Element { - const { className, isMarked, showValidation, fieldErrors, validationLabelInfo, completer, isLoading } = props; + const { className, isMarked, showValidation, fieldErrors, validationLabelInfo, completer, isLoading, enableLiveAutocompletion } = props; const { value, onValueChange, ref, ...inputProps } = props.inputProps; const [editorFocused, setEditorFocused] = useState(false); @@ -65,6 +66,7 @@ export function CustomCompleterAceEditor(props: CustomCompleterAceEditorProps): ...inputProps, }} customAceEditorCompleter={completer} + enableLiveAutocompletion={enableLiveAutocompletion} /> @@ -47,6 +49,7 @@ export function FixedValuesSetting({ typ={typ} name={name} initialValue={initialValue} + inputLabel={userDefinedListInputLabel} /> )} diff --git a/designer/client/src/components/graph/node-modal/fragment-input-definition/settings/variants/fields/UserDefinedListInput.tsx b/designer/client/src/components/graph/node-modal/fragment-input-definition/settings/variants/fields/UserDefinedListInput.tsx index f2d5ffe005c..f6891664f31 100644 --- a/designer/client/src/components/graph/node-modal/fragment-input-definition/settings/variants/fields/UserDefinedListInput.tsx +++ b/designer/client/src/components/graph/node-modal/fragment-input-definition/settings/variants/fields/UserDefinedListInput.tsx @@ -27,6 +27,7 @@ interface Props { typ: ReturnedType; name: string; initialValue: FixedValuesOption; + inputLabel: string; } export const UserDefinedListInput = ({ @@ -39,6 +40,7 @@ export const UserDefinedListInput = ({ typ, name, initialValue, + inputLabel, }: Props) => { const { t } = useTranslation(); const [temporaryListItem, setTemporaryListItem] = useState(""); @@ -166,7 +168,7 @@ export const UserDefinedListInput = ({ return ( - {t("fragment.addListItem", "Add list item:")} + {inputLabel} ) ], [props, t], ); + const { isCategoryFieldVisible, isAllCombinationsLoading } = useGetAllCombinations({ + processCategory: scenario.processCategory, + processingMode: scenario.processingMode, + processEngine: scenario.engineSetupName, + }); const displayStatus = !scenario.isArchived && !scenario.isFragment; const displayLabels = scenario.labels.length !== 0; + if (isAllCombinationsLoading) { + return ; + } + return ( ) {i18next.t("scenarioDetails.label.processingMode", "Processing mode")} {getProcessingModeVariantName(scenario.processingMode)} - - {i18next.t("scenarioDetails.label.category", "Category")} - {scenario.processCategory} - + {isCategoryFieldVisible && ( + + {i18next.t("scenarioDetails.label.category", "Category")} + {scenario.processCategory} + + )} {i18next.t("scenarioDetails.label.engine", "Engine")} {scenario.engineSetupName} diff --git a/designer/client/src/components/toolbars/scenarioDetails/CategoryDetails.tsx b/designer/client/src/components/toolbars/scenarioDetails/CategoryDetails.tsx index 60b2d1b3b3b..340644d1985 100644 --- a/designer/client/src/components/toolbars/scenarioDetails/CategoryDetails.tsx +++ b/designer/client/src/components/toolbars/scenarioDetails/CategoryDetails.tsx @@ -1,39 +1,27 @@ -import React, { useEffect, useState } from "react"; -import { useProcessFormDataOptions } from "../../useProcessFormDataOptions"; -import HttpService, { ScenarioParametersCombination } from "../../../http/HttpService"; +import React from "react"; import { Skeleton, Typography } from "@mui/material"; import { Scenario } from "../../Process/types"; +import { useGetAllCombinations } from "../../useGetAllCombinations"; +import { useTranslation } from "react-i18next"; export const CategoryDetails = ({ scenario }: { scenario: Scenario }) => { - const [allCombinations, setAllCombinations] = useState([]); - const [isAllCombinationsLoading, setIsAllCombinationsLoading] = useState(false); - - const { isCategoryFieldVisible } = useProcessFormDataOptions({ - allCombinations, - value: { - processCategory: scenario.processCategory, - processingMode: scenario.processingMode, - processEngine: scenario.engineSetupName, - }, + const { t } = useTranslation(); + const { isAllCombinationsLoading, isCategoryFieldVisible } = useGetAllCombinations({ + processCategory: scenario.processCategory, + processingMode: scenario.processingMode, + processEngine: scenario.engineSetupName, }); - useEffect(() => { - setIsAllCombinationsLoading(true); - HttpService.fetchScenarioParametersCombinations() - .then((response) => { - setAllCombinations(response.data.combinations); - }) - .finally(() => { - setIsAllCombinationsLoading(false); - }); - }, []); - return ( <> {isAllCombinationsLoading ? ( ) : ( - isCategoryFieldVisible && {scenario.processCategory} / + isCategoryFieldVisible && ( + + {scenario.processCategory} / + + ) )} ); diff --git a/designer/client/src/components/toolbars/scenarioDetails/ScenarioDetailsComponents.tsx b/designer/client/src/components/toolbars/scenarioDetails/ScenarioDetailsComponents.tsx index 21810a99560..1693c49d114 100644 --- a/designer/client/src/components/toolbars/scenarioDetails/ScenarioDetailsComponents.tsx +++ b/designer/client/src/components/toolbars/scenarioDetails/ScenarioDetailsComponents.tsx @@ -1,4 +1,5 @@ import { css, styled, Typography } from "@mui/material"; +import i18next from "i18next"; export const PanelScenarioDetails = styled("div")( ({ theme }) => css` @@ -26,6 +27,10 @@ export const ScenarioDetailsItemWrapper = styled("div")( export const ProcessName = styled(Typography)``; +ProcessName.defaultProps = { + title: i18next.t("panels.scenarioDetails.tooltip.name", "Name"), +}; + export const ProcessRename = styled(ProcessName)(({ theme }) => ({ color: theme.palette.warning.main, })); diff --git a/designer/client/src/components/toolbars/scenarioDetails/ScenarioLabels.tsx b/designer/client/src/components/toolbars/scenarioDetails/ScenarioLabels.tsx index 34235357cc6..6126f30b30c 100644 --- a/designer/client/src/components/toolbars/scenarioDetails/ScenarioLabels.tsx +++ b/designer/client/src/components/toolbars/scenarioDetails/ScenarioLabels.tsx @@ -21,11 +21,19 @@ import i18next from "i18next"; import { editScenarioLabels } from "../../../actions/nk"; import { debounce } from "lodash"; import { ScenarioLabelValidationError } from "../../Labels/types"; +import { useTranslation } from "react-i18next"; interface AddLabelProps { onClick: () => void; } +const labelUniqueValidation = (label: string) => ({ + label, + messages: [ + i18next.t("panels.scenarioDetails.labels.validation.uniqueValue", "This label already exists. Please enter a unique value."), + ], +}); + const AddLabel = ({ onClick }: AddLabelProps) => { return ( { + const { t } = useTranslation(); const scenarioLabels = useSelector(getScenarioLabels); const scenarioLabelOptions: LabelOption[] = useMemo(() => scenarioLabels.map(toLabelOption), [scenarioLabels]); const initialScenarioLabelOptionsErrors = useSelector(getScenarioLabelsErrors).filter((error) => @@ -125,9 +134,12 @@ export const ScenarioLabels = ({ readOnly }: Props) => { setIsEdited(true); }; - const isInputInSelectedOptions = (inputValue: string): boolean => { - return scenarioLabelOptions.some((option) => inputValue === toLabelValue(option)); - }; + const isInputInSelectedOptions = useCallback( + (inputValue: string): boolean => { + return scenarioLabelOptions.some((option) => inputValue === toLabelValue(option)); + }, + [scenarioLabelOptions], + ); const inputHelperText = useMemo(() => { if (inputErrors.length !== 0) { @@ -151,9 +163,13 @@ export const ScenarioLabels = ({ readOnly }: Props) => { } } + if (isInputInSelectedOptions(newInput)) { + setInputErrors((prevState) => [...prevState, labelUniqueValidation(newInput)]); + } + setInputTyping(false); }, 500); - }, []); + }, [isInputInSelectedOptions]); const validateSelectedOptions = useMemo(() => { return debounce(async (labels: LabelOption[]) => { @@ -338,6 +354,9 @@ export const ScenarioLabels = ({ readOnly }: Props) => { const labelError = labelOptionsErrors.find((error) => error.label === toLabelValue(option)); return ( { + const [allCombinations, setAllCombinations] = useState([]); + const [engineSetupErrors, setEngineSetupErrors] = useState>({}); + const [isAllCombinationsLoading, setIsAllCombinationsLoading] = useState(false); + + const { isCategoryFieldVisible } = useProcessFormDataOptions({ + allCombinations, + value: { + processCategory, + processingMode, + processEngine, + }, + }); + + useEffect(() => { + setIsAllCombinationsLoading(true); + HttpService.fetchScenarioParametersCombinations() + .then((response) => { + setAllCombinations(response.data.combinations); + setEngineSetupErrors(response.data.engineSetupErrors); + }) + .finally(() => { + setIsAllCombinationsLoading(false); + }); + }, []); + + return { allCombinations, isAllCombinationsLoading, isCategoryFieldVisible, engineSetupErrors }; +}; diff --git a/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/validation/PrettyValidationErrors.scala b/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/validation/PrettyValidationErrors.scala index 729dd2ccc4d..a2328fda58d 100644 --- a/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/validation/PrettyValidationErrors.scala +++ b/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/validation/PrettyValidationErrors.scala @@ -197,10 +197,10 @@ object PrettyValidationErrors { "Please check component definition", paramName = Some(qualifiedParamFieldName(paramName = paramName, subFieldName = Some(TypFieldName))) ) - case RequireValueFromEmptyFixedList(paramName, _) => + case EmptyFixedListForRequiredField(paramName, _) => node( - s"Required parameter '${paramName.value}' cannot be a member of an empty fixed list", - description = "Please check component definition", + s"Non-empty fixed list of values have to be declared for required parameter", + description = "Please add a value to the list of possible values", paramName = Some(qualifiedParamFieldName(paramName = paramName, subFieldName = Some(InputModeFieldName))) ) case ExpressionParserCompilationErrorInFragmentDefinition(message, _, paramName, subFieldName, originalExpr) => diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/fragment/FragmentRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/fragment/FragmentRepository.scala index 17466f241ea..75c12a4edb4 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/fragment/FragmentRepository.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/fragment/FragmentRepository.scala @@ -1,10 +1,10 @@ package pl.touk.nussknacker.ui.process.fragment +import cats.implicits.toTraverseOps import pl.touk.nussknacker.engine.api.process.{ProcessName, ProcessingType} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess import pl.touk.nussknacker.ui.process.ScenarioQuery import pl.touk.nussknacker.ui.process.repository.FetchingProcessRepository -import pl.touk.nussknacker.ui.process.repository.ProcessDBQueryRepository.ProcessNotFoundError import pl.touk.nussknacker.ui.security.api.LoggedUser import scala.concurrent.duration._ @@ -46,10 +46,7 @@ class DefaultFragmentRepository(processRepository: FetchingProcessRepository[Fut )(implicit user: LoggedUser): Future[Option[CanonicalProcess]] = { processRepository .fetchProcessId(fragmentName) - .flatMap { processIdOpt => - val processId = processIdOpt.getOrElse(throw ProcessNotFoundError(fragmentName)) - processRepository.fetchLatestProcessDetailsForProcessId[CanonicalProcess](processId) - } + .flatMap(_.map(processRepository.fetchLatestProcessDetailsForProcessId[CanonicalProcess]).sequence.map(_.flatten)) .map(_.map(_.json)) } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/test/ScenarioTestService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/test/ScenarioTestService.scala index 56f3a05611d..d5c1366fa17 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/test/ScenarioTestService.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/test/ScenarioTestService.scala @@ -3,8 +3,8 @@ package pl.touk.nussknacker.ui.process.test import com.carrotsearch.sizeof.RamUsageEstimator import com.typesafe.scalalogging.LazyLogging import pl.touk.nussknacker.engine.api.ProcessVersion -import pl.touk.nussknacker.engine.api.definition.StringParameterEditor -import pl.touk.nussknacker.engine.api.definition.Parameter +import pl.touk.nussknacker.engine.api.definition.{DualParameterEditor, Parameter, StringParameterEditor} +import pl.touk.nussknacker.engine.api.editor.DualEditorMode import pl.touk.nussknacker.engine.api.graph.ScenarioGraph import pl.touk.nussknacker.engine.api.test.ScenarioTestData import pl.touk.nussknacker.engine.api.typed.CanBeSubclassDeterminer @@ -17,7 +17,6 @@ import pl.touk.nussknacker.ui.api.description.NodesApiEndpoints.Dtos.TestSourceP import pl.touk.nussknacker.ui.api.TestDataSettings import pl.touk.nussknacker.ui.definition.DefinitionsService import pl.touk.nussknacker.ui.process.deployment.ScenarioTestExecutorService -import pl.touk.nussknacker.ui.process.label.ScenarioLabel import pl.touk.nussknacker.ui.processreport.{NodeCount, ProcessCounter, RawCount} import pl.touk.nussknacker.ui.security.api.LoggedUser import pl.touk.nussknacker.ui.uiresolving.UIProcessResolver @@ -133,10 +132,11 @@ class ScenarioTestService( private def assignUserFriendlyEditor(uiSourceParameter: UISourceParameters): UISourceParameters = { val adaptedParameters = uiSourceParameter.parameters.map { uiParameter => - if (CanBeSubclassDeterminer.canBeSubclassOf(uiParameter.typ, Typed.apply(classOf[String])).isValid) { - uiParameter.copy(editor = StringParameterEditor) - } else { - uiParameter + uiParameter.editor match { + case DualParameterEditor(StringParameterEditor, DualEditorMode.RAW) + if uiParameter.typ.canBeSubclassOf(Typed[String]) => + uiParameter.copy(editor = StringParameterEditor) + case _ => uiParameter } } uiSourceParameter.copy(parameters = adaptedParameters) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/NodesApiHttpServiceBusinessSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/NodesApiHttpServiceBusinessSpec.scala index 7a2947fbe62..d1836e32e7d 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/NodesApiHttpServiceBusinessSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/NodesApiHttpServiceBusinessSpec.scala @@ -499,22 +499,34 @@ class NodesApiHttpServiceBusinessSpec |}""".stripMargin) } - "return 404 for not existent fragment validation" in { - val fragmentName = "fragment" + "return 200 for fragment input node referencing fragment that doesn't exist" in { + val nonExistingFragmentName = "non-existing-fragment" given() .applicationState { createSavedScenario(exampleScenario) } .basicAuthAllPermUser() - .jsonBody(exampleNodeValidationRequestForFragment(fragmentName)) + .jsonBody(exampleNodeValidationRequestForFragment(nonExistingFragmentName)) .when() .post(s"$nuDesignerHttpAddress/api/nodes/${exampleScenario.name}/validation") .Then() - .statusCode(404) - .body( - equalTo(s"No scenario $fragmentName found") - ) + .statusCode(200) + .equalsJsonBody(s"""{ + | "parameters": null, + | "expressionType": null, + | "validationErrors": [ + | { + | "typ": "UnknownFragment", + | "message": "Unknown fragment", + | "description": "Node fragment uses fragment non-existing-fragment which is missing", + | "fieldName": null, + | "errorType": "SaveAllowed", + | "details": null + | } + | ], + | "validationPerformed": true + |}""".stripMargin) } } diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/TestingApiHttpServiceSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/TestingApiHttpServiceSpec.scala index f233dbbdaf2..2c7d0e62824 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/TestingApiHttpServiceSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/TestingApiHttpServiceSpec.scala @@ -6,10 +6,13 @@ import io.circe.syntax.EncoderOps import io.restassured.RestAssured.given import io.restassured.module.scala.RestAssuredSupport.AddThenToResponse import org.scalatest.freespec.AnyFreeSpecLike +import pl.touk.nussknacker.engine.api.definition.FixedExpressionValue import pl.touk.nussknacker.engine.api.graph.ScenarioGraph -import pl.touk.nussknacker.engine.api.parameter.ParameterName +import pl.touk.nussknacker.engine.api.parameter.{ParameterName, ValueInputWithFixedValuesProvided} import pl.touk.nussknacker.engine.build.ScenarioBuilder +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess import pl.touk.nussknacker.engine.graph.expression.Expression +import pl.touk.nussknacker.engine.graph.node.FragmentInputDefinition.{FragmentClazzRef, FragmentParameter} import pl.touk.nussknacker.test.base.it.{NuItTest, WithSimplifiedConfigScenarioHelper} import pl.touk.nussknacker.test.config.{ WithBusinessCaseRestAssuredUsersExtensions, @@ -46,6 +49,36 @@ class TestingApiHttpServiceSpec "Value" -> Expression.spel("#input") ) + private val fragmentFixedParameter = FragmentParameter( + ParameterName("paramFixedString"), + FragmentClazzRef[java.lang.String], + initialValue = Some(FixedExpressionValue("'uno'", "uno")), + hintText = None, + valueEditor = Some( + ValueInputWithFixedValuesProvided( + fixedValuesList = List( + FixedExpressionValue("'uno'", "uno"), + FixedExpressionValue("'due'", "due"), + ), + allowOtherValue = false + ) + ), + valueCompileTimeValidation = None + ) + + private val fragmentRawStringParameter = FragmentParameter( + ParameterName("paramRawString"), + FragmentClazzRef[java.lang.String], + initialValue = None, + hintText = None, + valueEditor = None, + valueCompileTimeValidation = None + ) + + private def exampleFragment(parameter: FragmentParameter) = ScenarioBuilder + .fragmentWithRawParameters("fragment", parameter) + .fragmentOutput("fragmentEnd", "output", "out" -> "'hola'".spel) + "The endpoint for capabilities should" - { "return valid capabilities for scenario with all capabilities" in { given() @@ -172,6 +205,122 @@ class TestingApiHttpServiceSpec |""".stripMargin ) } + "generate parameters for fragment with fixed list parameter" in { + val fragment = exampleFragment(fragmentFixedParameter) + given() + .applicationState { + createSavedScenario(fragment) + } + .when() + .basicAuthAllPermUser() + .jsonBody(canonicalGraphStr(fragment)) + .post(s"$nuDesignerHttpAddress/api/scenarioTesting/${fragment.name}/parameters") + .Then() + .statusCode(200) + .equalsJsonBody( + s"""[ + | { + | "sourceId": "fragment", + | "parameters": [ + | { + | "name": "paramFixedString", + | "typ": { + | "display": "String", + | "type": "TypedClass", + | "refClazzName": "java.lang.String", + | "params": [ + | + | ] + | }, + | "editor": { + | "possibleValues": [ + | { + | "expression": "", + | "label": "" + | }, + | { + | "expression": "'uno'", + | "label": "uno" + | }, + | { + | "expression": "'due'", + | "label": "due" + | } + | ], + | "type": "FixedValuesParameterEditor" + | }, + | "defaultValue": { + | "language": "spel", + | "expression": "'uno'" + | }, + | "additionalVariables": { + | + | }, + | "variablesToHide": [ + | + | ], + | "branchParam": false, + | "hintText": null, + | "label": "paramFixedString", + | "requiredParam": false + | } + | ] + | } + |] + |""".stripMargin + ) + } + "Generate parameters with simplified (single) editor for fragment with raw string parameter" in { + val fragment = exampleFragment(fragmentRawStringParameter) + given() + .applicationState { + createSavedScenario(fragment) + } + .when() + .basicAuthAllPermUser() + .jsonBody(canonicalGraphStr(fragment)) + .post(s"$nuDesignerHttpAddress/api/scenarioTesting/${fragment.name}/parameters") + .Then() + .statusCode(200) + .equalsJsonBody( + s"""[ + | { + | "sourceId": "fragment", + | "parameters": [ + | { + | "name": "paramRawString", + | "typ": { + | "display": "String", + | "type": "TypedClass", + | "refClazzName": "java.lang.String", + | "params": [ + | + | ] + | }, + | "editor": { + | "type": "StringParameterEditor" + | }, + | "defaultValue": { + | "language": "spel", + | "expression": "" + | }, + | "additionalVariables": { + | + | }, + | "variablesToHide": [ + | + | ], + | "branchParam": false, + | "hintText": null, + | "label": "paramRawString", + | "requiredParam": false + | } + | ] + | } + |] + |""".stripMargin + ) + } "return error if scenario does not exists" in { val notExistingScenarioName = exampleScenario.name.value + "_2" given() @@ -249,4 +398,7 @@ class TestingApiHttpServiceSpec private val exampleScenarioGraph = CanonicalProcessConverter.toScenarioGraph(exampleScenario) private val exampleScenarioGraphStr = Encoder[ScenarioGraph].apply(exampleScenarioGraph).toString() + + private def canonicalGraphStr(canonical: CanonicalProcess) = + Encoder[ScenarioGraph].apply(CanonicalProcessConverter.toScenarioGraph(canonical)).toString() } diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/fragment/FragmentRepositorySpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/fragment/FragmentRepositorySpec.scala index a8245c93b75..7d3d00362b5 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/fragment/FragmentRepositorySpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/fragment/FragmentRepositorySpec.scala @@ -5,6 +5,7 @@ import akka.http.scaladsl.testkit.ScalatestRouteTest import org.scalatest.BeforeAndAfterEach import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import pl.touk.nussknacker.engine.api.process.ProcessName import pl.touk.nussknacker.test.VeryPatientScalaFutures import pl.touk.nussknacker.test.utils.domain.ProcessTestData.sampleFragmentName import pl.touk.nussknacker.test.base.it.NuResourcesTest @@ -36,4 +37,8 @@ class FragmentRepositorySpec ) } + it should "return None for missing fragment" in { + fragmentRepository.fetchLatestFragment(ProcessName("non-existing-fragment"))(adminUser).futureValue shouldBe None + } + } diff --git a/designer/submodules/packages/components/src/common/categoryChip.tsx b/designer/submodules/packages/components/src/common/categoryChip.tsx index f8e48cf7b57..4195b509142 100644 --- a/designer/submodules/packages/components/src/common/categoryChip.tsx +++ b/designer/submodules/packages/components/src/common/categoryChip.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useMemo } from "react"; import { Button, Chip, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; interface Props { category: string; @@ -23,6 +24,7 @@ export function CategoryChip({ category, filterValues, setFilter }: Props): JSX. } export function CategoryButton({ category, filterValues, setFilter }: Props): JSX.Element { + const { t } = useTranslation(); const isSelected = useMemo(() => filterValues.includes(category), [filterValues, category]); const onClick = useCallback( @@ -36,6 +38,7 @@ export function CategoryButton({ category, filterValues, setFilter }: Props): JS return ( filterValue.includes(value), [filterValue, value]); const onClick = useCallback( @@ -26,6 +28,7 @@ export function LabelChip({ id, value, filterValue, setFilter }: Props): JSX.Ele return ( ["createdBy", "modifiedBy"], []); const filterableValues = useMemo(() => { @@ -35,7 +36,7 @@ export function FiltersPart({ withSort, isLoading, data = [] }: { data: RowType[ label: (availableLabels?.labels || []).map((name) => ({ name })), processingMode: processingModeItems, }; - }, [data, filterableKeys, statusDefinitions, userData?.categories]); + }, [availableLabels?.labels, data, filterableKeys, statusDefinitions, userData?.categories]); const statusFilterLabels = statusDefinitions.reduce((map, obj) => { map[obj.name] = obj.displayableName; @@ -102,17 +103,19 @@ export function FiltersPart({ withSort, isLoading, data = [] }: { data: RowType[ })} /> - - - + {withCategoriesVisible && ( + + + + )} (); + const { withCategoriesVisible } = useScenariosWithCategoryVisible(); return (
- - / + {withCategoriesVisible && ( + <> + + / + + )} diff --git a/designer/submodules/packages/components/src/scenarios/list/processingMode.tsx b/designer/submodules/packages/components/src/scenarios/list/processingMode.tsx index 5ae98dcf026..3eb9a81b2cc 100644 --- a/designer/submodules/packages/components/src/scenarios/list/processingMode.tsx +++ b/designer/submodules/packages/components/src/scenarios/list/processingMode.tsx @@ -6,6 +6,7 @@ import i18next from "i18next"; import Streaming from "../../assets/icons/streaming.svg"; import Batch from "../../assets/icons/batch.svg"; import RequestResponse from "../../assets/icons/request-response.svg"; +import { useTranslation } from "react-i18next"; export enum ProcessingMode { "streaming" = "Unbounded-Stream", @@ -36,6 +37,7 @@ interface Props { filtersContext: FiltersContextType; } export const ProcessingModeItem = ({ processingMode, filtersContext }: Props) => { + const { t } = useTranslation(); const { setFilter, getFilter } = filtersContext; const filterValue = useMemo(() => getFilter("PROCESSING_MODE", true), [getFilter]); @@ -58,6 +60,7 @@ export const ProcessingModeItem = ({ processingMode, filtersContext }: Props) => return (