Skip to content

Commit

Permalink
Correctly display details of the node with dictKeyWithLabel params (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
mgoworko authored Feb 18, 2025
1 parent f4b37ac commit d4a9f23
Show file tree
Hide file tree
Showing 12 changed files with 285 additions and 21 deletions.
12 changes: 12 additions & 0 deletions designer/client/src/actions/nk/fetchVisualizationData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import { loadProcessToolbarsConfiguration } from "./loadProcessToolbarsConfigura
import { ProcessName } from "../../components/Process/types";
import HttpService from "../../http/HttpService";

// This function is responsible for the initial fetching of scenario visualization
// 1. Fetch (blocking, with await) latest scenario, but without validation, which makes it very quick.
// 2. Fetch (blocking, with await) process definition data for the processing type.
// 3. After requests 1 and 2 are made, and graph began loading in the browser, then simultaneously and non-blocking:
// - fetch scenario validation data
// - fetch toolbars configuration
// - fetch test capabilities
// - fetch sticky notes
// IMPORTANT: The initial fetch of the scenario graph is performed with flag `skipValidateAndResolve=true`.
// There are 2 effects of that:
// - there is no validation result in the response (it is fetched later, asynchronously)
// - the `dictKeyWithLabel` expressions may not be fully resolved (missing label, which needs to be fetched separately, see `DictParameterEditor`)
export function fetchVisualizationData(processName: ProcessName, onSuccess: () => void, onError: (error) => void): ThunkAction {
return async (dispatch) => {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Autocomplete, Box, SxProps, Theme, useTheme } from "@mui/material";
import HttpService, { ProcessDefinitionDataDictOption } from "../../../../../../http/HttpService";
import { getScenario } from "../../../../../../reducers/selectors/graph";
Expand Down Expand Up @@ -39,14 +39,7 @@ export const DictParameterEditor: ExtendedEditor<Props> = ({
const { menuOption } = selectStyled(theme);
const [options, setOptions] = useState<ProcessDefinitionDataDictOption[]>([]);
const [open, setOpen] = useState(false);
const [value, setValue] = useState(() => {
if (!expressionObj.expression) {
return null;
}

const parseObject = tryParseOrNull(expressionObj.expression);
return typeof parseObject === "object" ? parseObject : null;
});
const [value, setValue] = useState<ProcessDefinitionDataDictOption>();
const [inputValue, setInputValue] = useState("");
const [isFetching, setIsFetching] = useState(false);

Expand All @@ -64,6 +57,16 @@ export const DictParameterEditor: ExtendedEditor<Props> = ({
[dictId, scenario.processingType],
);

const fetchProcessDefinitionDataDictByKey = useCallback(
async (key: string) => {
setIsFetching(true);
const response = await HttpService.fetchProcessDefinitionDataDictByKey(scenario.processingType, dictId, key);
setIsFetching(false);
return response;
},
[dictId, scenario.processingType],
);

const debouncedUpdateOptions = useMemo(() => {
return debounce(async (value: string) => {
const fetchedOptions = await fetchProcessDefinitionDataDict(value);
Expand All @@ -73,6 +76,28 @@ export const DictParameterEditor: ExtendedEditor<Props> = ({

const isValid = isEmpty(fieldErrors);

// This logic is needed, because scenario is initially loaded without full validation data.
// In that case the label is missing, and we need to fetch it separately.
useEffect(() => {
if (!expressionObj.expression) return;
const parseObject = tryParseOrNull(expressionObj.expression);
if (!parseObject) return;
fetchProcessDefinitionDataDictByKey(parseObject?.key).then((response) => {
if (response.status == "success") {
setValue(response.data);
} else {
setValue(parseObject);
}
});
}, [expressionObj, fetchProcessDefinitionDataDictByKey]);

// This condition means, that we should delay rendering this fragment when both conditions are met:
// - expression is defined, so we know that value is present, but we do not yet have enough information to render it (label)
// - value is not yet available - label is not yet loaded
if (!value && expressionObj?.expression) {
return;
}

return (
<Box className={nodeValue}>
<Autocomplete
Expand Down Expand Up @@ -108,7 +133,7 @@ export const DictParameterEditor: ExtendedEditor<Props> = ({
}}
open={open}
noOptionsText={i18next.t("editors.dictParameterEditor.noOptionsFound", "No options found")}
getOptionLabel={(option) => option.label}
getOptionLabel={(option) => option.label ?? ""}
isOptionEqualToValue={() => true}
value={value}
inputValue={inputValue}
Expand Down
14 changes: 13 additions & 1 deletion designer/client/src/http/HttpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ type DictOption = {
label: string;
};

type ResponseStatus = { status: "success" } | { status: "error"; error: AxiosError<string> };
type ResponseStatus = { status: "success"; data?: any } | { status: "error"; error: AxiosError<string> };

class HttpService {
//TODO: Move show information about error to another place. HttpService should avoid only action (get / post / etc..) - handling errors should be in another place.
Expand Down Expand Up @@ -977,6 +977,18 @@ class HttpService {
);
}

async fetchProcessDefinitionDataDictByKey(processingType: ProcessingType, dictId: string, key: string): Promise<ResponseStatus> {
try {
const { data } = await api.get<ProcessDefinitionDataDictOption>(
`/processDefinitionData/${processingType}/dicts/${dictId}/entryByKey?key=${key}`,
);
return { status: "success", data };
} catch (error) {
await this.#addError(i18next.t("notification.error.failedToFetchProcessDefinitionDataDict", "Failed to fetch options"), error);
return { status: "error", error };
}
}

fetchAllProcessDefinitionDataDicts(processingType: ProcessingType, refClazzName: string, type = "TypedClass") {
return api
.post<DictOption[]>(`/processDefinitionData/${processingType}/dicts`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import pl.touk.nussknacker.ui.api.description.DictApiEndpoints.DictError
import pl.touk.nussknacker.ui.api.description.DictApiEndpoints.DictError.{
MalformedTypingResult,
NoDict,
NoDictEntryForKey,
NoProcessingType
}
import pl.touk.nussknacker.ui.api.description.DictApiEndpoints.Dtos.DictDto
Expand All @@ -29,14 +30,14 @@ class DictApiHttpService(
private val dictApiEndpoints = new DictApiEndpoints(authManager.authenticationEndpointInput())

expose {
dictApiEndpoints.dictionaryEntryQueryEndpoint
dictApiEndpoints.dictionaryEntryByLabelQueryEndpoint
.serverSecurityLogic(authorizeKnownUser[DictError])
.serverLogic { implicit loggedUser: LoggedUser => queryParams =>
val (processingType, dictId, labelPattern) = queryParams

processingTypeData.forProcessingType(processingType) match {
case Some((dictQueryService, _, _)) =>
dictQueryService.queryEntriesByLabel(dictId, labelPattern) match {
dictQueryService.queryEntriesByLabel(dictId, labelPattern.value) match {
case Valid(dictEntries) => dictEntries.map(success)
case Invalid(DictNotDeclared(_)) => Future.successful(businessError(NoDict(dictId)))
}
Expand All @@ -46,6 +47,28 @@ class DictApiHttpService(
}
}

expose {
dictApiEndpoints.dictionaryEntryByKeyQueryEndpoint
.serverSecurityLogic(authorizeKnownUser[DictError])
.serverLogic { implicit loggedUser: LoggedUser => queryParams =>
val (processingType, dictId, key) = queryParams

processingTypeData.forProcessingType(processingType) match {
case Some((dictQueryService, _, _)) =>
dictQueryService.queryEntryByKey(dictId, key.value) match {
case Valid(dictEntryOptF) =>
dictEntryOptF.map {
case Some(value) => success(value)
case None => businessError(NoDictEntryForKey(dictId, key.value))
}
case Invalid(DictNotDeclared(_)) =>
Future.successful(businessError(NoDict(dictId)))
}
case None => Future.successful(businessError(NoProcessingType(processingType)))
}
}
}

expose {
dictApiEndpoints.dictionaryListEndpoint
.serverSecurityLogic(authorizeKnownUser[DictError])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ class ProcessesResources(
}
}
} ~ (get & skipValidateAndResolveParameter & skipNodeResultsParameter) {
// FIXME: The `skipValidateAndResolve` flag has a non-trivial side effect.
// Besides skipping validation (that is the intended and obvious result) it causes the `dictKeyWithLabel` expressions to miss the label field.
// It happens, because in the current implementation we need the full compilation and type resolving in order to obtain the dict expression label.
(skipValidateAndResolve, skipNodeResults) =>
complete {
processService.getLatestProcessWithDetails(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,26 @@ import pl.touk.nussknacker.ui.api.description.DictApiEndpoints.DictError
import pl.touk.nussknacker.ui.api.description.DictApiEndpoints.DictError.{
MalformedTypingResult,
NoDict,
NoDictEntryForKey,
NoProcessingType
}
import pl.touk.nussknacker.ui.api.description.DictApiEndpoints.Dtos._
import sttp.model.StatusCode.{BadRequest, NotFound, Ok}
import sttp.tapir.json.circe._
import sttp.tapir._
import sttp.tapir.json.circe._

import scala.language.implicitConversions

class DictApiEndpoints(auth: EndpointInput[AuthCredentials]) extends BaseEndpointDefinitions {

lazy val dictionaryEntryQueryEndpoint: SecuredEndpoint[(String, String, String), DictError, List[DictEntry], Any] =
lazy val dictionaryEntryByLabelQueryEndpoint
: SecuredEndpoint[(String, String, LabelPatternDto), DictError, List[DictEntry], Any] =
baseNuApiEndpoint
.summary("Get list of dictionary entries matching the label pattern")
.tag("Dictionary")
.get
.in(
"processDefinitionData" / path[String]("processingType") / "dicts" / path[String]("dictId") / "entry" / query[
String
]("label")
)
.in("processDefinitionData" / path[String]("processingType") / "dicts" / path[String]("dictId") / "entry")
.in(query[LabelPatternDto]("label"))
.out(
statusCode(Ok).and(
jsonBody[List[DictEntry]]
Expand All @@ -51,6 +50,36 @@ class DictApiEndpoints(auth: EndpointInput[AuthCredentials]) extends BaseEndpoin
)
.withSecurity(auth)

lazy val dictionaryEntryByKeyQueryEndpoint: SecuredEndpoint[(String, String, KeyDto), DictError, DictEntry, Any] =
baseNuApiEndpoint
.summary("Get list of dictionary entries matching the label pattern")
.tag("Dictionary")
.get
.in("processDefinitionData" / path[String]("processingType") / "dicts" / path[String]("dictId") / "entryByKey")
.in(query[KeyDto]("key"))
.out(
statusCode(Ok).and(
jsonBody[DictEntry]
)
)
.errorOut(
oneOf[DictError](
oneOfVariantFromMatchType(
NotFound,
plainBody[NoProcessingType]
),
oneOfVariantFromMatchType(
NotFound,
plainBody[NoDict]
),
oneOfVariantFromMatchType(
NotFound,
plainBody[NoDictEntryForKey]
)
)
)
.withSecurity(auth)

lazy val dictionaryListEndpoint: SecuredEndpoint[(String, DictListRequestDto), DictError, List[DictDto], Any] =
baseNuApiEndpoint
.summary("Get list of available dictionaries with value type compatible with expected type")
Expand Down Expand Up @@ -83,6 +112,18 @@ object DictApiEndpoints {

object Dtos {

final case class LabelPatternDto(value: String)

implicit val labelPatternDtoCodec: Codec[String, LabelPatternDto, CodecFormat.TextPlain] = {
Codec.string.map(str => LabelPatternDto(str))(_.value)
}

final case class KeyDto(value: String)

implicit val keyDtoCodec: Codec[String, KeyDto, CodecFormat.TextPlain] = {
Codec.string.map(str => KeyDto(str))(_.value)
}

@JsonCodec
case class DictListRequestDto(expectedType: Json)

Expand All @@ -103,6 +144,8 @@ object DictApiEndpoints {
object DictError {
final case class NoDict(dictId: String) extends DictError

final case class NoDictEntryForKey(dictId: String, key: String) extends DictError

final case class NoProcessingType(processingType: ProcessingType) extends DictError

final case class MalformedTypingResult(msg: String) extends DictError
Expand All @@ -113,6 +156,12 @@ object DictApiEndpoints {
)
}

implicit val noDictEntryForKeyCodec: Codec[String, NoDictEntryForKey, CodecFormat.TextPlain] = {
BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[NoDictEntryForKey](e =>
s"No dict entry found in dictionary with id: ${e.dictId} for key: ${e.key}"
)
}

implicit val noProcessingTypeCodec: Codec[String, NoProcessingType, CodecFormat.TextPlain] = {
BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[NoProcessingType](e =>
s"Processing type: ${e.processingType} not found"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,35 @@ class DictApiHttpServiceSpec
.equalsJsonBody("""[]""".stripMargin)
}

"return dict entry suggestions for existing key" in {
given()
.when()
.basicAuthAllPermUser()
.get(
s"$nuDesignerHttpAddress/api/processDefinitionData/" +
s"${Streaming.stringify}/dicts/$existingDictId/entryByKey?key=H0000ff"
)
.Then()
.statusCode(200)
.equalsJsonBody("""{
| "key" : "H0000ff",
| "label" : "Blue"
|}""".stripMargin)
}

"fail to return dict entry suggestions for non-existing key" in {
given()
.when()
.basicAuthAllPermUser()
.get(
s"$nuDesignerHttpAddress/api/processDefinitionData/" +
s"${Streaming.stringify}/dicts/$existingDictId/entryByKey?key=H1000ff"
)
.Then()
.statusCode(404)
.equalsPlainBody(s"No dict entry found in dictionary with id: $existingDictId for key: H1000ff".stripMargin)
}

"fail to return entry suggestions for non-existing dictionary" in {
given()
.when()
Expand Down
Loading

0 comments on commit d4a9f23

Please sign in to comment.