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 2c48838da69..95c0671c722 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 @@ -430,6 +430,10 @@ object ProcessCompilationError { extends ProcessCompilationError with ScenarioPropertiesError + final case class ScenarioLabelValidationError(label: String, description: String) + extends ProcessCompilationError + with ScenarioPropertiesError + final case class SpecificDataValidationError(paramName: ParameterName, message: String) extends ProcessCompilationError with ScenarioPropertiesError diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should allow adding input parameters and display used fragment graph in modal #4.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should allow adding input parameters and display used fragment graph in modal #4.png index 8bdc30a14e4..b6f7e64d25a 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 #4.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should allow adding input parameters and display used fragment graph in modal #4.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 #6.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should allow adding input parameters and display used fragment graph in modal #6.png index b7851ab99b5..5bb4036fb75 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 #6.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should allow adding input parameters and display used fragment graph in modal #6.png differ diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should display dead-ended fragment correct #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should display dead-ended fragment correct #0.png index cc171721195..784d14fc988 100644 Binary files a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should display dead-ended fragment correct #0.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Fragment should display dead-ended fragment correct #0.png differ diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process initially clean should import JSON and save #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process initially clean should import JSON and save #0.png index a670d8d9e57..9ea113e09e8 100644 Binary files a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process initially clean should import JSON and save #0.png and b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Process initially clean should import JSON and save #0.png differ diff --git a/designer/client/cypress/e2e/labels.cy.ts b/designer/client/cypress/e2e/labels.cy.ts new file mode 100644 index 00000000000..d3e882c720c --- /dev/null +++ b/designer/client/cypress/e2e/labels.cy.ts @@ -0,0 +1,92 @@ +describe("Scenario labels", () => { + const seed = "process"; + + before(() => { + cy.deleteAllTestProcesses({ filter: seed, force: true }); + }); + + beforeEach(() => { + cy.mockWindowDate(); + }); + + afterEach(() => { + cy.deleteAllTestProcesses({ filter: seed, force: true }); + }); + + describe("designer", () => { + it("should allow to set labels for new process", () => { + cy.visitNewProcess(seed).as("processName"); + + cy.intercept("PUT", "/api/processes/*").as("save"); + + cy.intercept("POST", "/api/scenarioLabels/validation").as("labelvalidation"); + + cy.get("[data-testid=AddLabel]").should("be.visible").click(); + + cy.get("[data-testid=LabelInput]").as("labelInput"); + + cy.get("@labelInput").should("be.visible").click().type("tagX"); + + cy.wait("@labelvalidation"); + + cy.get('.MuiAutocomplete-popper li[data-option-index="0"]').contains('Add label "tagX"').click(); + + cy.get("[data-testid=scenario-label-0]").should("be.visible").contains("tagX"); + + cy.get("@labelInput").should("be.visible").click().type("tag2"); + + cy.wait("@labelvalidation"); + + cy.get('.MuiAutocomplete-popper li[data-option-index="0"]').contains('Add label "tag2"').click(); + + cy.get("@labelInput").type("{enter}"); + + cy.get("[data-testid=scenario-label-1]").should("be.visible").contains("tag2"); + + cy.contains(/^save/i).should("be.enabled").click(); + cy.contains(/^ok$/i).should("be.enabled").click(); + cy.wait("@save").its("response.statusCode").should("eq", 200); + + cy.viewport(1500, 800); + + cy.get("[data-testid=scenario-label-0]").should("be.visible").contains("tag2"); + cy.get("[data-testid=scenario-label-1]").should("be.visible").contains("tagX"); + + cy.get("@labelInput").should("be.visible").click().type("very long tag"); + + cy.wait("@labelvalidation").then((_) => cy.wait(100)); + + cy.get("@labelInput").should("be.visible").contains("Incorrect value 'very long tag'"); + + cy.contains(/^save/i).should("be.disabled"); + }); + + it("should show labels for scenario", () => { + cy.visitNewProcess(seed).then((processName) => cy.addLabelsToNewProcess(processName, ["tag1", "tag3"])); + + cy.viewport(1500, 800); + + cy.get("[data-testid=scenario-label-0]").should("be.visible").contains("tag1"); + cy.get("[data-testid=scenario-label-1]").should("be.visible").contains("tag3"); + }); + }); + + describe("scenario list", () => { + it("should allow to filter scenarios by label", () => { + cy.visitNewProcess(seed).then((processName) => cy.addLabelsToNewProcess(processName, ["tag1", "tag3"])); + cy.visitNewProcess(seed).then((processName) => cy.addLabelsToNewProcess(processName, ["tag2", "tag3"])); + cy.visitNewProcess(seed).then((processName) => cy.addLabelsToNewProcess(processName, ["tag4"])); + cy.visitNewProcess(seed); + + cy.visit("/"); + + cy.contains("button", /label/i).click(); + + cy.get("ul[role='menu']").within(() => { + cy.contains(/tag2/i).click(); + }); + + cy.contains(/1 of the 4 rows match the filters/i).should("be.visible"); + }); + }); +}); diff --git a/designer/client/cypress/support/process.ts b/designer/client/cypress/support/process.ts index bf60cfdc511..170e8a0ce16 100644 --- a/designer/client/cypress/support/process.ts +++ b/designer/client/cypress/support/process.ts @@ -14,6 +14,7 @@ declare global { importTestProcess: typeof importTestProcess; visitNewProcess: typeof visitNewProcess; visitNewFragment: typeof visitNewFragment; + addLabelsToNewProcess: typeof addLabelsToNewProcess; postFormData: typeof postFormData; visitProcess: typeof visitProcess; getNode: typeof getNode; @@ -93,6 +94,26 @@ function visitNewFragment(name?: string, fixture?: string, category?: string) { }); } +function addLabelsToNewProcess(name?: string, labels?: string[]) { + return cy.visitProcess(name).then((processName) => { + cy.intercept("PUT", "/api/processes/*").as("save"); + cy.intercept("POST", "/api/scenarioLabels/validation").as("labelValidation"); + cy.get("[data-testid=AddLabel]").should("be.visible").click(); + cy.get("[data-testid=LabelInput]").should("be.visible").click().as("labelInput"); + + labels.forEach((label) => { + cy.get("@labelInput").type(label); + cy.wait("@labelValidation"); + cy.get('.MuiAutocomplete-popper li[data-option-index="0"]').contains(label).click(); + }); + + cy.contains(/^save/i).should("be.enabled").click(); + cy.contains(/^ok$/i).should("be.enabled").click(); + cy.wait("@save").its("response.statusCode").should("eq", 200); + return cy.wrap(processName); + }); +} + function deleteTestProcess(processName: string, force?: boolean) { const url = `/api/processes/${processName}`; @@ -168,6 +189,7 @@ function importTestProcess(name: string, fixture = "testProcess") { cy.request("PUT", `/api/processes/${name}`, { comment: "import test data", scenarioGraph: response.scenarioGraph, + scenarioLabels: [], }); return cy.wrap(name); }); @@ -292,6 +314,7 @@ Cypress.Commands.add("createTestFragment", createTestFragment); Cypress.Commands.add("importTestProcess", importTestProcess); Cypress.Commands.add("visitNewProcess", visitNewProcess); Cypress.Commands.add("visitNewFragment", visitNewFragment); +Cypress.Commands.add("addLabelsToNewProcess", addLabelsToNewProcess); Cypress.Commands.add("postFormData", postFormData); Cypress.Commands.add("visitProcess", visitProcess); Cypress.Commands.add("getNode", getNode); diff --git a/designer/client/src/actions/actionTypes.ts b/designer/client/src/actions/actionTypes.ts index 53cdc447e8a..577b0fd71db 100644 --- a/designer/client/src/actions/actionTypes.ts +++ b/designer/client/src/actions/actionTypes.ts @@ -21,6 +21,7 @@ export type ActionTypes = | "RESET_SELECTION" | "EDIT_NODE" | "PROCESS_RENAME" + | "EDIT_LABELS" | "SHOW_METRICS" | "UPDATE_TEST_CAPABILITIES" | "UPDATE_TEST_FORM_PARAMETERS" diff --git a/designer/client/src/actions/nk/editNode.ts b/designer/client/src/actions/nk/editNode.ts index a9271446987..b765adbaf42 100644 --- a/designer/client/src/actions/nk/editNode.ts +++ b/designer/client/src/actions/nk/editNode.ts @@ -17,6 +17,17 @@ export type RenameProcessAction = { name: string; }; +export type EditScenarioLabels = { + type: "EDIT_LABELS"; + labels: string[]; +}; + +export function editScenarioLabels(scenarioLabels: string[]) { + return (dispatch) => { + dispatch({ type: "EDIT_LABELS", labels: scenarioLabels }); + }; +} + export function editNode(scenarioBefore: Scenario, before: NodeType, after: NodeType, outputEdges?: Edge[]): ThunkAction { return async (dispatch) => { const { processName, scenarioGraph } = await dispatch(calculateProcessAfterChange(scenarioBefore, before, after, outputEdges)); diff --git a/designer/client/src/actions/nk/node.ts b/designer/client/src/actions/nk/node.ts index 1f098a837ce..d1f80cb4435 100644 --- a/designer/client/src/actions/nk/node.ts +++ b/designer/client/src/actions/nk/node.ts @@ -1,7 +1,7 @@ import { Edge, EdgeType, NodeId, NodeType, ProcessDefinitionData, ValidationResult } from "../../types"; import { ThunkAction } from "../reduxTypes"; import { layoutChanged, Position } from "./ui/layout"; -import { EditNodeAction, RenameProcessAction } from "./editNode"; +import { EditNodeAction, EditScenarioLabels, RenameProcessAction } from "./editNode"; import { getProcessDefinitionData } from "../../reducers/selectors/settings"; import { batchGroupBy } from "../../reducers/graph/batchGroupBy"; import NodeUtils from "../../components/graph/NodeUtils"; @@ -154,4 +154,5 @@ export type NodeActions = | NodesWithEdgesAddedAction | ValidationResultAction | EditNodeAction - | RenameProcessAction; + | RenameProcessAction + | EditScenarioLabels; diff --git a/designer/client/src/common/ProcessUtils.ts b/designer/client/src/common/ProcessUtils.ts index 081543af45a..32086e23995 100644 --- a/designer/client/src/common/ProcessUtils.ts +++ b/designer/client/src/common/ProcessUtils.ts @@ -16,12 +16,12 @@ import { import { RootState } from "../reducers"; import { isProcessRenamed } from "../reducers/selectors/graph"; import { Scenario } from "src/components/Process/types"; +import { ScenarioLabelValidationError } from "../components/Labels/types"; class ProcessUtils { nothingToSave = (state: RootState): boolean => { const scenario = state.graphReducer.scenario; const savedProcessState = state.graphReducer.history.past[0]?.scenario || state.graphReducer.history.present.scenario; - const omitValidation = (details: ScenarioGraph) => omit(details, ["validationResult"]); const processRenamed = isProcessRenamed(state); @@ -33,7 +33,14 @@ class ProcessUtils { return true; } - return !savedProcessState || isEqual(omitValidation(scenario.scenarioGraph), omitValidation(savedProcessState.scenarioGraph)); + const labelsFor = (scenario: Scenario): string[] => { + return scenario.labels ? scenario.labels.slice().sort((a, b) => a.localeCompare(b)) : []; + }; + + const isGraphUpdated = isEqual(omitValidation(scenario.scenarioGraph), omitValidation(savedProcessState.scenarioGraph)); + const areScenarioLabelsUpdated = isEqual(labelsFor(scenario), labelsFor(savedProcessState)); + + return !savedProcessState || (isGraphUpdated && areScenarioLabelsUpdated); }; canExport = (state: RootState): boolean => { @@ -87,6 +94,12 @@ class ProcessUtils { return isEmpty(this.getValidationErrors(scenario)?.processPropertiesErrors); }; + getLabelsErrors = (scenario: Scenario): ScenarioLabelValidationError[] => { + return this.getValidationResult(scenario) + .errors.globalErrors.filter((e) => e.error.typ == "ScenarioLabelValidationError") + .map((e) => { label: e.error.fieldName, messages: [e.error.description] }); + }; + getValidationErrors(scenario: Scenario): ValidationErrors { return this.getValidationResult(scenario).errors; } diff --git a/designer/client/src/components/Labels/types.ts b/designer/client/src/components/Labels/types.ts new file mode 100644 index 00000000000..6c4dac4e1ca --- /dev/null +++ b/designer/client/src/components/Labels/types.ts @@ -0,0 +1,12 @@ +export type AvailableScenarioLabels = { + labels: string[]; +}; + +export type ScenarioLabelValidationError = { + label: string; + messages: string[]; +}; + +export type ScenarioLabelsValidationResponse = { + validationErrors: ScenarioLabelValidationError[]; +}; diff --git a/designer/client/src/components/Process/types.ts b/designer/client/src/components/Process/types.ts index 0cb923e66d9..66e02ce888e 100644 --- a/designer/client/src/components/Process/types.ts +++ b/designer/client/src/components/Process/types.ts @@ -53,6 +53,7 @@ export interface Scenario { createdAt: Instant; modifiedAt: Instant; createdBy: string; + labels: string[]; lastAction?: ProcessActionType; lastDeployedAction?: ProcessActionType; state: ProcessStateType; diff --git a/designer/client/src/components/modals/MoreScenarioDetailsDialog.tsx b/designer/client/src/components/modals/MoreScenarioDetailsDialog.tsx index e44086ad9e9..b75cec44dd9 100644 --- a/designer/client/src/components/modals/MoreScenarioDetailsDialog.tsx +++ b/designer/client/src/components/modals/MoreScenarioDetailsDialog.tsx @@ -54,6 +54,7 @@ function MoreScenarioDetailsDialog(props: WindowContentProps) }, [scenario.processingMode]); const displayStatus = !scenario.isArchived && !scenario.isFragment; + const displayLabels = scenario.labels.length !== 0; return ( ) {i18next.t("scenarioDetails.label.engine", "Engine")} {scenario.engineSetupName} + {displayLabels && ( + + {i18next.t("scenarioDetails.label.labels", "Labels")} + {scenario.labels.join(", ")} + + )} {i18next.t("scenarioDetails.label.created", "Created")} {moment(scenario.createdAt).format(DATE_FORMAT)} diff --git a/designer/client/src/components/modals/SaveProcessDialog.tsx b/designer/client/src/components/modals/SaveProcessDialog.tsx index a49a2f74748..17e073b19f7 100644 --- a/designer/client/src/components/modals/SaveProcessDialog.tsx +++ b/designer/client/src/components/modals/SaveProcessDialog.tsx @@ -7,7 +7,7 @@ import { displayCurrentProcessVersion, displayProcessActivity, loadProcessToolba import { PromptContent } from "../../windowManager"; import { CommentInput } from "../comment/CommentInput"; import { ThunkAction } from "../../actions/reduxTypes"; -import { getScenarioGraph, getProcessName, getProcessUnsavedNewName, isProcessRenamed } from "../../reducers/selectors/graph"; +import { getScenarioGraph, getProcessName, getProcessUnsavedNewName, isProcessRenamed, getScenarioLabels } from "../../reducers/selectors/graph"; import HttpService from "../../http/HttpService"; import { ActionCreators as UndoActionCreators } from "redux-undo"; import { visualizationUrl } from "../../common/VisualizationUrl"; @@ -24,9 +24,10 @@ export function SaveProcessDialog(props: WindowContentProps): JSX.Element { const state = getState(); const scenarioGraph = getScenarioGraph(state); const currentProcessName = getProcessName(state); + const labels = getScenarioLabels(state); // save changes before rename and force same processName everywhere - await HttpService.saveProcess(currentProcessName, scenarioGraph, comment); + await HttpService.saveProcess(currentProcessName, scenarioGraph, comment, labels); const unsavedNewName = getProcessUnsavedNewName(state); const isRenamed = isProcessRenamed(state) && (await HttpService.changeProcessName(currentProcessName, unsavedNewName)); diff --git a/designer/client/src/components/tips/error/ErrorTips.tsx b/designer/client/src/components/tips/error/ErrorTips.tsx index 1c1b4db4695..15162a0523d 100644 --- a/designer/client/src/components/tips/error/ErrorTips.tsx +++ b/designer/client/src/components/tips/error/ErrorTips.tsx @@ -13,9 +13,9 @@ export const ErrorTips = ({ errors, showDetails, scenario }: Props) => { () => globalErrors.map((error, index) => isEmpty(error.nodeIds) ? ( - +
{error.error.message} - +
) : ( { const scenario = useSelector((state: RootState) => getScenario(state)); const isRenamePending = useSelector((state: RootState) => isProcessRenamed(state)); const unsavedNewName = useSelector((state: RootState) => getProcessUnsavedNewName(state)); const processState = useSelector((state: RootState) => getProcessState(state)); + const loggedUser = useSelector((state: RootState) => getLoggedUser(state)); const transitionKey = ProcessStateUtils.getTransitionKey(scenario, processState); @@ -56,6 +59,7 @@ const ScenarioDetails = memo((props: ToolbarPanelProps) => { {scenario.name} )} + diff --git a/designer/client/src/components/toolbars/scenarioDetails/ScenarioLabels.tsx b/designer/client/src/components/toolbars/scenarioDetails/ScenarioLabels.tsx new file mode 100644 index 00000000000..9d0ea087736 --- /dev/null +++ b/designer/client/src/components/toolbars/scenarioDetails/ScenarioLabels.tsx @@ -0,0 +1,354 @@ +import { useDispatch, useSelector } from "react-redux"; +import { getScenarioLabels, getScenarioLabelsErrors } from "../../../reducers/selectors/graph"; +import { + Autocomplete, + AutocompleteInputChangeReason, + Box, + Chip, + createFilterOptions, + Link, + styled, + SxProps, + TextField, + Theme, + Typography, + useTheme, +} from "@mui/material"; +import { selectStyled } from "../../../stylesheets/SelectStyled"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import HttpService from "../../../http/HttpService"; +import i18next from "i18next"; +import { editScenarioLabels } from "../../../actions/nk"; +import { debounce } from "lodash"; +import { ScenarioLabelValidationError } from "../../Labels/types"; + +interface AddLabelProps { + onClick: () => void; +} + +const AddLabel = ({ onClick }: AddLabelProps) => { + return ( + ({ cursor: "pointer", textDecoration: "none", color: theme.palette.text.primary })} + onClick={onClick} + > + {i18next.t("panels.scenarioDetails.labels.addLabelTitle", "+ Add label")} + + ); +}; + +const StyledAutocomplete = styled(Autocomplete)<{ isEdited: boolean }>(({ isEdited, theme }) => ({ + ".MuiFormControl-root": { + margin: 0, + flexDirection: "column", + }, + ...{ + ...(!isEdited && { + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, + ".MuiInputBase-input": { + outline: "none", + }, + ".MuiOutlinedInput-notchedOutline": { + border: "none", + }, + }), + }, +})); + +const filter = createFilterOptions(); + +type LabelOption = { + title: string; + value: string; + inputValue?: string; +}; + +function toLabelOption(value: string): LabelOption { + return { + title: value, + value: value, + }; +} + +function toLabelValue(option: LabelOption): string { + return option.value; +} + +function formatErrors(errors: ScenarioLabelValidationError[]): string { + return errors + .map( + (error) => + `${i18next.t("panels.scenarioDetails.labels.incorrectLabel", "Incorrect value")} '${error.label}': ${error.messages.join( + ",", + )}`, + ) + .join("\n"); +} + +interface Props { + readOnly: boolean; +} + +export const ScenarioLabels = ({ readOnly }: Props) => { + const scenarioLabels = useSelector(getScenarioLabels); + const scenarioLabelOptions: LabelOption[] = useMemo(() => scenarioLabels.map(toLabelOption), [scenarioLabels]); + const initialScenarioLabelOptionsErrors = useSelector(getScenarioLabelsErrors).filter((error) => + scenarioLabelOptions.some((option) => toLabelValue(option) === error.label), + ); + const [labelOptionsErrors, setLabelOptionsErrors] = useState(initialScenarioLabelOptionsErrors); + const [showEditor, setShowEditor] = useState(scenarioLabelOptions.length !== 0); + + const theme = useTheme(); + const { menuOption } = selectStyled(theme); + const dispatch = useDispatch(); + + const [isFetching, setIsFetching] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [isEdited, setIsEdited] = useState(false); + const [options, setOptions] = useState([]); + + const [inputTyping, setInputTyping] = useState(false); + const [inputErrors, setInputErrors] = useState([]); + + const handleAddLabelClick = (): void => { + setShowEditor(true); + setIsEdited(true); + }; + + const isInputInSelectedOptions = (inputValue: string): boolean => { + return scenarioLabelOptions.some((option) => inputValue === toLabelValue(option)); + }; + + const inputHelperText = useMemo(() => { + if (inputErrors.length !== 0) { + return formatErrors(inputErrors.concat(labelOptionsErrors)); + } + + if (!isOpen && labelOptionsErrors.length !== 0) { + return formatErrors(labelOptionsErrors); + } + + return undefined; + }, [inputErrors, labelOptionsErrors, isOpen]); + + const validateInput = useMemo(() => { + return debounce(async (newInput: string) => { + if (newInput !== "") { + const response = await HttpService.validateScenarioLabels([newInput]); + + if (response.status === 200) { + setInputErrors(response.data.validationErrors); + } + } + + setInputTyping(false); + }, 500); + }, []); + + const validateSelectedOptions = useMemo(() => { + return debounce(async (labels: LabelOption[]) => { + const values = labels.map(toLabelValue); + const response = await HttpService.validateScenarioLabels(values); + + if (response.status === 200) { + const validationError = response.data.validationErrors; + setLabelOptionsErrors(validationError); + } + }, 500); + }, []); + + const fetchAvailableLabelOptions = useCallback(async () => { + try { + setIsFetching(true); + const { data } = await HttpService.fetchScenarioLabels(); + return data.labels.map(toLabelOption); + } finally { + setIsFetching(false); + } + }, []); + + const setLabels = (options: LabelOption[]) => { + const newLabels = options.map(toLabelValue); + dispatch(editScenarioLabels(newLabels)); + }; + + useEffect(() => { + validateSelectedOptions(scenarioLabelOptions); + }, [scenarioLabelOptions, validateSelectedOptions]); + + useEffect(() => { + // show add label component if user clears all labels and looses focus + if (!isEdited && scenarioLabelOptions.length === 0) { + setShowEditor(false); + } + }, [scenarioLabelOptions, isEdited]); + + useEffect(() => { + // show editor if user use edit options (undo, redo) and editor is hidden + if (!showEditor && scenarioLabelOptions.length !== 0) { + setShowEditor(true); + } + }, [scenarioLabelOptions, showEditor]); + + return ( + <> + {!showEditor ? ( + + ) : ( + { + const filtered = filter(options, params); + + const { inputValue } = params; + + const isInProposedOptions = filtered.some((option) => + typeof option === "string" ? false : inputValue === toLabelValue(option), + ); + const isInSelectedOptions = isInputInSelectedOptions(inputValue); + + if (inputValue !== "" && !isInProposedOptions && !isInSelectedOptions && !inputTyping && inputErrors.length === 0) { + filtered.push({ + inputValue, + title: `${i18next.t("panels.scenarioDetails.labels.addNewLabel", "Add label")} "${inputValue}"`, + value: inputValue, + }); + } + + return inputErrors.length !== 0 ? [] : filtered; + }} + filterSelectedOptions={true} + getOptionLabel={(option: string | LabelOption) => { + if (typeof option === "string") { + return option; + } + return option.title; + }} + getOptionKey={(option: string | LabelOption) => { + if (typeof option === "string") { + return option; + } + return toLabelValue(option); + }} + isOptionEqualToValue={(v1: LabelOption, v2: LabelOption) => v1.value === v2.value} + loading={isFetching || inputTyping} + clearOnBlur + loadingText={ + inputTyping + ? i18next.t("panels.scenarioDetails.labels.labelTyping", "Typing...") + : i18next.t("panels.scenarioDetails.labels.labelsLoading", "Loading...") + } + multiple + noOptionsText={i18next.t("panels.scenarioDetails.labels.noAvailableLabels", "No labels")} + onBlur={() => { + setIsEdited(false); + setInputErrors([]); + if (scenarioLabelOptions.length === 0) { + setShowEditor(false); + } + }} + onChange={(_, values: (string | LabelOption)[]) => { + const labelOptions = values.map((value) => { + if (typeof value === "string") { + return toLabelOption(value); + } else if (value.inputValue) { + return toLabelOption(value.inputValue); + } else { + return value; + } + }); + setLabels(labelOptions); + }} + onClose={() => { + setIsOpen(false); + }} + onFocus={() => { + setIsEdited(true); + }} + onInputChange={(_, newInputValue: string, reason: AutocompleteInputChangeReason) => { + if (reason === "input") { + setInputTyping(true); + } + setInputErrors([]); + validateInput(newInputValue); + }} + onOpen={async () => { + const fetchedOptions = await fetchAvailableLabelOptions(); + setOptions(fetchedOptions); + setIsOpen(true); + }} + open={isOpen} + options={options} + renderInput={(params) => ( +
+ { + const input = (event.target as HTMLInputElement).value; + + if ( + event.key === "Enter" && + (inputErrors.length !== 0 || inputTyping || isInputInSelectedOptions(input)) + ) { + event.stopPropagation(); + } + }, + }} + /> +
+ )} + renderOption={(props, option, _, ownerState) => { + return ( + } {...props} aria-selected={false}> + {ownerState.getOptionLabel(option)} + + ); + }} + renderTags={(value: (string | LabelOption)[], getTagProps) => { + return value + .filter((v) => typeof v !== "string") + .map((option: LabelOption, index: number) => { + const { key, ...tagProps } = getTagProps({ index }); + const props = isEdited ? { ...tagProps } : {}; + const labelError = labelOptionsErrors.find((error) => error.label === toLabelValue(option)); + return ( + + ); + }); + }} + size="small" + value={scenarioLabelOptions} + /> + )} + + ); +}; diff --git a/designer/client/src/containers/event-tracking/use-register-tracking-events.ts b/designer/client/src/containers/event-tracking/use-register-tracking-events.ts index a7731a5f5e0..6c2556e8b5a 100644 --- a/designer/client/src/containers/event-tracking/use-register-tracking-events.ts +++ b/designer/client/src/containers/event-tracking/use-register-tracking-events.ts @@ -76,6 +76,7 @@ enum FilterEventsSelector { ScenariosByProcessingMode = "SCENARIOS_BY_PROCESSING_MODE", ScenariosByCategory = "SCENARIOS_BY_CATEGORY", ScenariosByAuthor = "SCENARIOS_BY_AUTHOR", + ScenariosByLabel = "SCENARIOS_BY_LABEL", ComponentUsagesByAuthor = "COMPONENT_USAGES_BY_AUTHOR", ComponentUsagesByOther = "COMPONENT_USAGES_BY_OTHER", ComponentsByGroup = "COMPONENTS_BY_GROUP", diff --git a/designer/client/src/http/HttpService.ts b/designer/client/src/http/HttpService.ts index d7b4a45b34e..0776de1c467 100644 --- a/designer/client/src/http/HttpService.ts +++ b/designer/client/src/http/HttpService.ts @@ -29,6 +29,7 @@ import { CaretPosition2d, ExpressionSuggestion } from "../components/graph/node- import { GenericValidationRequest } from "../actions/nk/genericAction"; import { EventTrackingSelector } from "../containers/event-tracking"; import { EventTrackingSelectorType, EventTrackingType } from "../containers/event-tracking/use-register-tracking-events"; +import { AvailableScenarioLabels, ScenarioLabelsValidationResponse } from "../components/Labels/types"; type HealthCheckProcessDeploymentType = { status: string; @@ -277,6 +278,16 @@ class HttpService { ); } + fetchScenarioLabels() { + return api + .get(`/scenarioLabels`) + .catch((error) => + Promise.reject( + this.#addError(i18next.t("notification.error.cannotFetchScenarioLabels", "Cannot fetch scenario labels"), error), + ), + ); + } + fetchProcessToolbarsConfiguration(processName) { const promise = api.get>(`/processes/${encodeURIComponent(processName)}/toolbars`); promise.catch((error) => @@ -465,6 +476,17 @@ class HttpService { return promise; } + validateScenarioLabels(labels: string[]): Promise> { + const data = { labels: labels }; + return api + .post(`/scenarioLabels/validation`, data) + .catch((error) => + Promise.reject( + this.#addError(i18next.t("notification.error.cannotValidateScenarioLabels", "Cannot validate scenario labels"), error), + ), + ); + } + getExpressionSuggestions(processingType: string, request: ExpressionSuggestionRequest): Promise> { const promise = api.post(`/parameters/${encodeURIComponent(processingType)}/suggestions`, request); promise.catch((error) => @@ -593,8 +615,8 @@ class HttpService { } //to prevent closing edit node modal and corrupting graph display - saveProcess(processName: ProcessName, scenarioGraph: ScenarioGraph, comment: string) { - const data = { scenarioGraph: this.#sanitizeScenarioGraph(scenarioGraph), comment: comment }; + saveProcess(processName: ProcessName, scenarioGraph: ScenarioGraph, comment: string, labels: string[]) { + const data = { scenarioGraph: this.#sanitizeScenarioGraph(scenarioGraph), comment: comment, scenarioLabels: labels }; return api.put(`/processes/${encodeURIComponent(processName)}`, data).catch((error) => { this.#addError(i18next.t("notification.error.failedToSave", "Failed to save"), error, true); return Promise.reject(error); diff --git a/designer/client/src/reducers/graph/reducer.ts b/designer/client/src/reducers/graph/reducer.ts index de12e74593a..3f3d28aad87 100644 --- a/designer/client/src/reducers/graph/reducer.ts +++ b/designer/client/src/reducers/graph/reducer.ts @@ -149,6 +149,15 @@ const graphReducer: Reducer = (state = emptyGraphState, action) => { unsavedNewName: action.name, }; } + case "EDIT_LABELS": { + return { + ...state, + scenario: { + ...state.scenario, + labels: action.labels, + }, + }; + } case "DELETE_NODES": { return action.ids.reduce((state, idToDelete) => { const stateAfterNodeDelete = updateAfterNodeDelete(state, idToDelete); diff --git a/designer/client/src/reducers/selectors/graph.ts b/designer/client/src/reducers/selectors/graph.ts index 232bb045b70..41642d30f66 100644 --- a/designer/client/src/reducers/selectors/graph.ts +++ b/designer/client/src/reducers/selectors/graph.ts @@ -15,6 +15,8 @@ export const getScenario = createSelector(getGraph, (g) => g.scenario); export const getScenarioGraph = createSelector(getGraph, (g) => g.scenario.scenarioGraph || ({} as ScenarioGraph), { memoizeOptions: { equalityCheck: isEqual, resultEqualityCheck: isEqual }, }); + +export const getScenarioLabels = createSelector(getGraph, (g) => g.scenario.labels); export const getProcessNodesIds = createSelector(getScenarioGraph, (p) => NodeUtils.nodesFromScenarioGraph(p).map((n) => n.id)); export const getProcessName = createSelector(getScenario, (d) => d?.name); export const getProcessUnsavedNewName = createSelector(getGraph, (g) => g?.unsavedNewName); @@ -28,6 +30,7 @@ export const isPristine = (state: RootState): boolean => ProcessUtils.nothingToS export const hasError = createSelector(getScenario, (p) => !ProcessUtils.hasNoErrors(p)); export const hasWarnings = createSelector(getScenario, (p) => !ProcessUtils.hasNoWarnings(p)); export const hasPropertiesErrors = createSelector(getScenario, (p) => !ProcessUtils.hasNoPropertiesErrors(p)); +export const getScenarioLabelsErrors = createSelector(getScenario, (p) => ProcessUtils.getLabelsErrors(p)); export const getSelectionState = createSelector(getGraph, (g) => g.selectionState); export const getSelection = createSelector(getSelectionState, getScenarioGraph, (s, p) => NodeUtils.getAllNodesByIdWithEdges(s, p)); export const canModifySelectedNodes = createSelector(getSelectionState, (s) => !isEmpty(s)); diff --git a/designer/listener-api/src/main/scala/pl/touk/nussknacker/ui/listener/ListenerScenarioWithDetails.scala b/designer/listener-api/src/main/scala/pl/touk/nussknacker/ui/listener/ListenerScenarioWithDetails.scala index e9932440498..eceb69a92e2 100644 --- a/designer/listener-api/src/main/scala/pl/touk/nussknacker/ui/listener/ListenerScenarioWithDetails.scala +++ b/designer/listener-api/src/main/scala/pl/touk/nussknacker/ui/listener/ListenerScenarioWithDetails.scala @@ -35,7 +35,7 @@ trait ListenerScenarioWithDetails { def createdBy: String - def tags: Option[List[String]] + def scenarioLabels: List[String] def lastDeployedAction: Option[ProcessAction] diff --git a/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/scenariodetails/ScenarioWithDetails.scala b/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/scenariodetails/ScenarioWithDetails.scala index 2133fd8a49d..e8149d1e171 100644 --- a/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/scenariodetails/ScenarioWithDetails.scala +++ b/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/scenariodetails/ScenarioWithDetails.scala @@ -31,7 +31,7 @@ final case class ScenarioWithDetails( modifiedBy: String, createdAt: Instant, createdBy: String, - tags: Option[List[String]], + override val labels: List[String], lastDeployedAction: Option[ProcessAction], lastStateAction: Option[ProcessAction], lastAction: Option[ProcessAction], diff --git a/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/scenariodetails/ScenarioWithDetailsForMigrations.scala b/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/scenariodetails/ScenarioWithDetailsForMigrations.scala index 4ff1744437c..6a665561718 100644 --- a/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/scenariodetails/ScenarioWithDetailsForMigrations.scala +++ b/designer/restmodel/src/main/scala/pl/touk/nussknacker/restmodel/scenariodetails/ScenarioWithDetailsForMigrations.scala @@ -14,6 +14,7 @@ import sttp.tapir.Schema override val isFragment: Boolean, override val processingType: ProcessingType, override val processCategory: String, + override val labels: List[String], override val scenarioGraph: Option[ScenarioGraph], override val validationResult: Option[ValidationResult], override val history: Option[List[ScenarioVersion]], @@ -36,6 +37,7 @@ trait BaseScenarioWithDetailsForMigrations { def isFragment: Boolean def processingType: ProcessingType def processCategory: String + def labels: List[String] def scenarioGraph: Option[ScenarioGraph] def validationResult: Option[ValidationResult] def history: Option[List[ScenarioVersion]] 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 990d60a1dee..729dd2ccc4d 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 @@ -155,6 +155,12 @@ object PrettyValidationErrors { node(s"Cannot disable fragment with multiple outputs", "Please check fragment definition") case DisablingNoOutputsFragment(_) => node(s"Cannot disable fragment with no outputs", "Please check fragment definition") + case ScenarioLabelValidationError(label, description) => + node( + message = s"Invalid scenario label: $label", + description = description, + paramName = Some(ParameterName(label)), + ) case MissingRequiredProperty(paramName, label, _) => missingRequiredProperty(typ, paramName.value, label) case UnknownProperty(paramName, _) => unknownProperty(typ, paramName.value) case InvalidPropertyFixedValue(paramName, label, value, values, _) => diff --git a/designer/server/src/main/resources/db/migration/hsql/V1_055__CreateScenarioLabels.sql b/designer/server/src/main/resources/db/migration/hsql/V1_055__CreateScenarioLabels.sql new file mode 100644 index 00000000000..c9ff9de8c46 --- /dev/null +++ b/designer/server/src/main/resources/db/migration/hsql/V1_055__CreateScenarioLabels.sql @@ -0,0 +1,7 @@ +CREATE TABLE "scenario_labels" ( + "label" VARCHAR2(254) NOT NULL, + "scenario_id" INTEGER NOT NULL +); + +ALTER TABLE "scenario_labels" ADD CONSTRAINT "pk_scenario_label" PRIMARY KEY ("label", "scenario_id"); +ALTER TABLE "scenario_labels" ADD CONSTRAINT "label_scenario_fk" FOREIGN KEY ("scenario_id") REFERENCES "processes" ("id") ON DELETE CASCADE; diff --git a/designer/server/src/main/resources/db/migration/postgres/V1_055__CreateScenarioLabels.sql b/designer/server/src/main/resources/db/migration/postgres/V1_055__CreateScenarioLabels.sql new file mode 100644 index 00000000000..516f59f253f --- /dev/null +++ b/designer/server/src/main/resources/db/migration/postgres/V1_055__CreateScenarioLabels.sql @@ -0,0 +1,7 @@ +CREATE TABLE "scenario_labels" ( + "label" VARCHAR(254) NOT NULL, + "scenario_id" INTEGER NOT NULL +); + +ALTER TABLE "scenario_labels" ADD CONSTRAINT "pk_scenario_label" PRIMARY KEY ("label", "scenario_id"); +ALTER TABLE "scenario_labels" ADD CONSTRAINT "label_scenario_fk" FOREIGN KEY ("scenario_id") REFERENCES "processes" ("id") ON DELETE CASCADE; diff --git a/designer/server/src/main/resources/defaultDesignerConfig.conf b/designer/server/src/main/resources/defaultDesignerConfig.conf index 12821ccc470..4d3f29bb038 100644 --- a/designer/server/src/main/resources/defaultDesignerConfig.conf +++ b/designer/server/src/main/resources/defaultDesignerConfig.conf @@ -210,6 +210,19 @@ testDataSettings: { resultsMaxBytes: 50000000 } +scenarioLabelSettings: { + validationRules = [ + { + validationPattern: "^[a-zA-Z0-9-_]+$", + validationMessage: "Scenario label can contain only alphanumeric characters, '-' and '_'" + } + { + validationPattern: "^.{1,20}$" + validationMessage: "Scenario label can contain up to 20 characters" + } + ] +} + surveySettings: { key: "welcome" text: "We are happy to see you using Nussknacker. It would help us a ton to know a little more about you and how Nussknacker is helping you. Fill out our survey and get a free full-day Nussknacker workshop!" diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ManagementResources.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ManagementResources.scala index 6971bffc73b..91afc5da7e7 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ManagementResources.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ManagementResources.scala @@ -20,6 +20,7 @@ import pl.touk.nussknacker.ui.api.description.NodesApiEndpoints.Dtos.{ TestFromParametersRequest, prepareTestFromParametersDecoder } +import pl.touk.nussknacker.ui.api.utils.ScenarioDetailsOps._ import pl.touk.nussknacker.ui.api.ProcessesResources.ProcessUnmarshallingError import pl.touk.nussknacker.ui.metrics.TimeMeasuring.measureTime import pl.touk.nussknacker.ui.process.ProcessService @@ -195,6 +196,7 @@ class ManagementResources( details.idWithNameUnsafe, scenarioGraph, details.isFragment, + details.scenarioLabels, RawScenarioTestData(testDataContent) ) .flatMap(mapResultsToHttpResponse) @@ -220,6 +222,7 @@ class ManagementResources( scenarioGraph, processName, details.isFragment, + details.scenarioLabels, testSampleSize ) match { case Left(error) => Future.failed(ProcessUnmarshallingError(error)) @@ -229,6 +232,7 @@ class ManagementResources( details.idWithNameUnsafe, scenarioGraph, details.isFragment, + details.scenarioLabels, rawScenarioTestData ) .flatMap(mapResultsToHttpResponse) @@ -257,6 +261,7 @@ class ManagementResources( process.idWithNameUnsafe, testParametersRequest.scenarioGraph, process.isFragment, + process.scenarioLabels, testParametersRequest.sourceParameters ) .flatMap(mapResultsToHttpResponse) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/NodesApiHttpService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/NodesApiHttpService.scala index e4771d3aa67..6ce2f1dfdc8 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/NodesApiHttpService.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/NodesApiHttpService.scala @@ -10,6 +10,7 @@ import pl.touk.nussknacker.engine.api.process.{ProcessName, ProcessingType} import pl.touk.nussknacker.engine.api.typed.typing.TypingResult import pl.touk.nussknacker.engine.spel.ExpressionSuggestion import pl.touk.nussknacker.restmodel.definition.UIValueParameter +import pl.touk.nussknacker.restmodel.scenariodetails.ScenarioWithDetails import pl.touk.nussknacker.ui.additionalInfo.AdditionalInfoProviders import pl.touk.nussknacker.ui.api.BaseHttpService.CustomAuthorizationError import pl.touk.nussknacker.ui.api.description.NodesApiEndpoints @@ -34,6 +35,7 @@ import pl.touk.nussknacker.ui.api.description.NodesApiEndpoints.Dtos.{ prepareTypingResultDecoder } import pl.touk.nussknacker.ui.api.utils.ScenarioHttpServiceExtensions +import pl.touk.nussknacker.ui.api.utils.ScenarioDetailsOps._ import pl.touk.nussknacker.ui.process.ProcessService import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider import pl.touk.nussknacker.ui.process.repository.ProcessDBQueryRepository.ProcessNotFoundError @@ -89,7 +91,7 @@ class NodesApiHttpService( modelData <- getModelData(scenario.processingType) nodeValidator = processingTypeToNodeValidator.forProcessingTypeUnsafe(scenario.processingType) nodeData <- dtoToNodeRequest(nodeValidationRequestDto, modelData) - validation <- getNodeValidation(nodeValidator, scenarioName, nodeData) + validation <- getNodeValidation(nodeValidator, scenario.name, nodeData) validationDto = NodeValidationResultDto.apply(validation) } yield validationDto } @@ -125,7 +127,12 @@ class NodesApiHttpService( scenario = ScenarioGraph(ProcessProperties(request.additionalFields), Nil, Nil) result = processingTypeToProcessValidator .forProcessingTypeUnsafe(scenarioWithDetails.processingType) - .validate(scenario, request.name, scenarioWithDetails.isFragment) + .validate( + scenario, + request.name, + scenarioWithDetails.isFragment, + scenarioWithDetails.scenarioLabels, + ) validation = NodeValidationResultDto( parameters = None, expressionType = None, diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ProcessesExportResources.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ProcessesExportResources.scala index 1dceac644a7..bd88d3e38a4 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ProcessesExportResources.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ProcessesExportResources.scala @@ -8,7 +8,9 @@ import io.circe.syntax._ import pl.touk.nussknacker.engine.api.graph.ScenarioGraph import pl.touk.nussknacker.engine.api.process.{ProcessName, ProcessingType} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.ui.api.utils.ScenarioDetailsOps._ import pl.touk.nussknacker.ui.process.ProcessService +import pl.touk.nussknacker.ui.process.label.ScenarioLabel import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider import pl.touk.nussknacker.ui.process.repository.DbProcessActivityRepository.ProcessActivity @@ -53,7 +55,8 @@ class ProcessesExportResources( process, processDetails.processingType, processDetails.name, - processDetails.isFragment + processDetails.isFragment, + processDetails.scenarioLabels, ) } } @@ -92,10 +95,12 @@ class ProcessesExportResources( processWithDictLabels: ScenarioGraph, processingType: ProcessingType, processName: ProcessName, - isFragment: Boolean + isFragment: Boolean, + scenarioLabels: List[ScenarioLabel] )(implicit user: LoggedUser): HttpResponse = { val processResolver = processResolvers.forProcessingTypeUnsafe(processingType) - val resolvedProcess = processResolver.validateAndResolve(processWithDictLabels, processName, isFragment) + val resolvedProcess = + processResolver.validateAndResolve(processWithDictLabels, processName, isFragment, scenarioLabels) fileResponse(resolvedProcess) } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/RemoteEnvironmentResources.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/RemoteEnvironmentResources.scala index f3aa0e1c413..9e1a3677798 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/RemoteEnvironmentResources.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/RemoteEnvironmentResources.scala @@ -75,6 +75,7 @@ class RemoteEnvironmentResources( details.processingMode, details.engineSetupName, details.processCategory, + details.labels, details.scenarioGraphUnsafe, details.name, details.isFragment diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ScenarioLabelsApiHttpService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ScenarioLabelsApiHttpService.scala new file mode 100644 index 00000000000..2ec974325e0 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ScenarioLabelsApiHttpService.scala @@ -0,0 +1,48 @@ +package pl.touk.nussknacker.ui.api + +import cats.data.Validated +import pl.touk.nussknacker.ui.api.description.ScenarioLabelsApiEndpoints +import pl.touk.nussknacker.ui.api.description.ScenarioLabelsApiEndpoints.Dtos._ +import pl.touk.nussknacker.ui.process.label.ScenarioLabelsService +import pl.touk.nussknacker.ui.security.api.{AuthManager, LoggedUser} + +import scala.concurrent.{ExecutionContext, Future} + +class ScenarioLabelsApiHttpService( + authManager: AuthManager, + service: ScenarioLabelsService +)(implicit executionContext: ExecutionContext) + extends BaseHttpService(authManager) { + + private val labelsApiEndpoints = new ScenarioLabelsApiEndpoints( + authManager.authenticationEndpointInput() + ) + + expose { + labelsApiEndpoints.scenarioLabelsEndpoint + .serverSecurityLogic(authorizeKnownUser[Unit]) + .serverLogicSuccess { implicit loggedUser: LoggedUser => _ => + service + .readLabels(loggedUser) + .map(labels => ScenarioLabels(labels)) + } + } + + expose { + labelsApiEndpoints.validateScenarioLabelsEndpoint + .serverSecurityLogic(authorizeKnownUser[Unit]) + .serverLogicSuccess { implicit loggedUser: LoggedUser => request => + Future.successful { + service.validatedScenarioLabels(request.labels) match { + case Validated.Valid(()) => + ScenarioLabelsValidationResponseDto(validationErrors = List.empty) + case Validated.Invalid(errors) => + ScenarioLabelsValidationResponseDto(validationErrors = + errors.map(e => ValidationError(label = e.label, messages = e.validationMessages.toList)).toList + ) + } + } + } + } + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/TestInfoResources.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/TestInfoResources.scala index aa992f8c263..c6271ddef0e 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/TestInfoResources.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/TestInfoResources.scala @@ -7,6 +7,7 @@ import com.typesafe.scalalogging.LazyLogging import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport import pl.touk.nussknacker.engine.api.graph.ScenarioGraph import pl.touk.nussknacker.engine.definition.test.TestingCapabilities +import pl.touk.nussknacker.ui.api.utils.ScenarioDetailsOps._ import pl.touk.nussknacker.ui.process.ProcessService import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider import pl.touk.nussknacker.ui.process.test.ScenarioTestService @@ -38,11 +39,21 @@ class TestInfoResources( val scenarioTestService = scenarioTestServices.forProcessingTypeUnsafe(processDetails.processingType) path("capabilities") { complete { - scenarioTestService.getTestingCapabilities(scenarioGraph, processName, processDetails.isFragment) + scenarioTestService.getTestingCapabilities( + scenarioGraph, + processName, + processDetails.isFragment, + processDetails.scenarioLabels + ) } } ~ path("testParameters") { complete { - scenarioTestService.testParametersDefinition(scenarioGraph, processName, processDetails.isFragment) + scenarioTestService.testParametersDefinition( + scenarioGraph, + processName, + processDetails.isFragment, + processDetails.scenarioLabels + ) } } ~ path("generate" / IntNumber) { testSampleSize => complete { @@ -50,6 +61,7 @@ class TestInfoResources( scenarioGraph, processName, processDetails.isFragment, + processDetails.scenarioLabels, testSampleSize ) match { case Left(error) => diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ValidationResources.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ValidationResources.scala index c16d87bf154..cdfcd82e9d4 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ValidationResources.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/ValidationResources.scala @@ -6,6 +6,7 @@ import io.circe.generic.JsonCodec import pl.touk.nussknacker.engine.api.graph.ScenarioGraph import pl.touk.nussknacker.engine.api.process.ProcessName import pl.touk.nussknacker.restmodel.scenariodetails.ScenarioWithDetails +import pl.touk.nussknacker.ui.api.utils.ScenarioDetailsOps._ import pl.touk.nussknacker.ui.process.ProcessService import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider import pl.touk.nussknacker.ui.security.api.LoggedUser @@ -32,7 +33,12 @@ class ValidationResources( FatalValidationError.renderNotAllowedAsError( processResolver .forProcessingTypeUnsafe(details.processingType) - .validateBeforeUiResolving(request.scenarioGraph, request.processName, details.isFragment) + .validateBeforeUiResolving( + request.scenarioGraph, + request.processName, + details.isFragment, + details.scenarioLabels + ) ) ) } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/MigrationApiEndpoints.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/MigrationApiEndpoints.scala index 58fcec972dd..c26eb8e7840 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/MigrationApiEndpoints.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/MigrationApiEndpoints.scala @@ -21,7 +21,8 @@ import pl.touk.nussknacker.restmodel.validation.ValidationResults.{ import pl.touk.nussknacker.security.AuthCredentials import pl.touk.nussknacker.ui.api.description.MigrationApiEndpoints.Dtos.{ MigrateScenarioRequestDto, - MigrateScenarioRequestDtoV1 + MigrateScenarioRequestDtoV1, + MigrateScenarioRequestDtoV2 } import pl.touk.nussknacker.ui.migrations.MigrationService.MigrationError import pl.touk.nussknacker.ui.migrations.MigrationService.MigrationError.{ @@ -52,22 +53,42 @@ class MigrationApiEndpoints(auth: EndpointInput[AuthCredentials]) extends BaseEn .post .in("migrate") .in( - jsonBody[MigrateScenarioRequestDto].example( - Example.of( - summary = Some("Migrate given scenario to current Nu instance"), - value = MigrateScenarioRequestDtoV1( - version = 1, - sourceEnvironmentId = "testEnv", - remoteUserName = "testUser", - processingMode = ProcessingMode.UnboundedStream, - engineSetupName = EngineSetupName("Flink"), - processCategory = "Category1", - scenarioGraph = exampleGraph, - processName = ProcessName("test"), - isFragment = false - ) - ) - ) + jsonBody[MigrateScenarioRequestDto] + // FIXME uncomment examples when discriminator validation will work in the NuDesignerApiAvailableToExposeYamlSpec - + // currently when examples are given, the validation in tests fails due to two schemas matching the example json +// .examples( +// List( +// Example.of( +// summary = Some("Migrate given scenario from version 2 to current Nu instance"), +// value = MigrateScenarioRequestDtoV2( +// version = 2, +// sourceEnvironmentId = "testEnv", +// remoteUserName = "testUser", +// processingMode = ProcessingMode.UnboundedStream, +// engineSetupName = EngineSetupName("Flink"), +// processCategory = "Category1", +// scenarioLabels = List("tag1", "tag2"), +// scenarioGraph = exampleGraph, +// processName = ProcessName("test"), +// isFragment = false +// ) +// ), +// Example.of( +// summary = Some("Migrate given scenario from version 1 to current Nu instance"), +// value = MigrateScenarioRequestDtoV1( +// version = 1, +// sourceEnvironmentId = "testEnv", +// remoteUserName = "testUser", +// processingMode = ProcessingMode.UnboundedStream, +// engineSetupName = EngineSetupName("Flink"), +// processCategory = "Category1", +// scenarioGraph = exampleGraph, +// processName = ProcessName("test"), +// isFragment = false +// ) +// ) +// ) +// ) ) .out(statusCode(Ok)) .errorOut(migrateEndpointErrorOutput) @@ -171,10 +192,24 @@ object MigrationApiEndpoints { isFragment: Boolean, ) extends MigrateScenarioRequestDto + @derive(encoder, decoder) + final case class MigrateScenarioRequestDtoV2( + override val version: Int, + sourceEnvironmentId: String, + remoteUserName: String, + processingMode: ProcessingMode, + engineSetupName: EngineSetupName, + processCategory: String, + scenarioLabels: List[String], + scenarioGraph: ScenarioGraph, + processName: ProcessName, + isFragment: Boolean, + ) extends MigrateScenarioRequestDto + /* NOTE TO DEVELOPER: - When implementing MigrateScenarioRequestDtoV2: + When implementing MigrateScenarioRequestDtoV3: 1. Review and update the parameter types and names if necessary. 2. Consider backward compatibility with existing code. @@ -186,13 +221,14 @@ object MigrationApiEndpoints { Remember to uncomment the class definition after implementation. @derive(encoder, decoder) - final case class MigrateScenarioRequestDtoV2( + final case class MigrateScenarioRequestDtoV3( override val version: Int, sourceEnvironmentId: String, remoteUserName: String, processingMode: ProcessingMode, engineSetupName: EngineSetupName, processCategory: String, + scenarioLabels: List[String], scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean, @@ -233,17 +269,20 @@ object MigrationApiEndpoints { import pl.touk.nussknacker.ui.api.TapirCodecs.ScenarioGraphCodec._ import pl.touk.nussknacker.ui.api.TapirCodecs.ProcessNameCodec._ implicit val migrateScenarioRequestV1Schema: Schema[MigrateScenarioRequestDtoV1] = Schema.derived - // implicit val migrateScenarioRequestV2Schema: Schema[MigrateScenarioRequestDtoV2] = Schema.derived + implicit val migrateScenarioRequestV2Schema: Schema[MigrateScenarioRequestDtoV2] = Schema.derived +// implicit val migrateScenarioRequestV3Schema: Schema[MigrateScenarioRequestDtoV3] = Schema.derived + val derived = Schema.derived[MigrateScenarioRequestDto] derived.schemaType match { case s: SchemaType.SCoproduct[_] => derived.copy(schemaType = s.addDiscriminatorField( FieldName("version"), - Schema.string, + Schema.schemaForInt, Map( "1" -> SchemaType.SRef(Schema.SName(classOf[MigrateScenarioRequestDtoV1].getSimpleName)), - // "2" -> SchemaType.SRef(Schema.SName(classOf[MigrateScenarioRequestDtoV2].getSimpleName)), + "2" -> SchemaType.SRef(Schema.SName(classOf[MigrateScenarioRequestDtoV2].getSimpleName)), +// "3" -> SchemaType.SRef(Schema.SName(classOf[MigrateScenarioRequestDtoV2].getSimpleName)), ) ) ) @@ -254,13 +293,15 @@ object MigrationApiEndpoints { implicit val encoder: Encoder[MigrateScenarioRequestDto] = Encoder.instance { case v1: MigrateScenarioRequestDtoV1 => v1.asJson - // case v2: MigrateScenarioRequestDtoV2 => v2.asJson + case v2: MigrateScenarioRequestDtoV2 => v2.asJson +// case v3: MigrateScenarioRequestDtoV3 => v3.asJson } implicit val decoder: Decoder[MigrateScenarioRequestDto] = Decoder.instance { cursor => cursor.downField("version").as[Int].flatMap { case 1 => cursor.as[MigrateScenarioRequestDtoV1] - // case 2 => cursor.as[MigrateScenarioRequestDtoV2] + case 2 => cursor.as[MigrateScenarioRequestDtoV2] +// case 3 => cursor.as[MigrateScenarioRequestDtoV3] case other => throw new IllegalStateException(s"Cannot decode migration request for version [$other]") } } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/ScenarioLabelsApiEndpoints.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/ScenarioLabelsApiEndpoints.scala new file mode 100644 index 00000000000..5015cf9e5de --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/ScenarioLabelsApiEndpoints.scala @@ -0,0 +1,115 @@ +package pl.touk.nussknacker.ui.api.description + +import derevo.circe.{decoder, encoder} +import derevo.derive +import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions +import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions.SecuredEndpoint +import pl.touk.nussknacker.security.AuthCredentials +import pl.touk.nussknacker.ui.api.description.ScenarioLabelsApiEndpoints.Dtos.{ + ScenarioLabels, + ScenarioLabelsValidationRequestDto, + ScenarioLabelsValidationResponseDto, + ValidationError +} +import sttp.model.StatusCode.Ok +import sttp.tapir.EndpointIO.Example +import sttp.tapir._ +import sttp.tapir.derevo.schema +import sttp.tapir.json.circe.jsonBody + +class ScenarioLabelsApiEndpoints(auth: EndpointInput[AuthCredentials]) extends BaseEndpointDefinitions { + + lazy val scenarioLabelsEndpoint: SecuredEndpoint[Unit, Unit, ScenarioLabels, Any] = + baseNuApiEndpoint + .summary("Service providing available scenario labels") + .tag("Scenario labels") + .get + .in("scenarioLabels") + .out( + statusCode(Ok).and( + jsonBody[ScenarioLabels] + .example( + Example.of( + summary = Some("List of available scenario labels"), + value = ScenarioLabels( + labels = List("Label_1", "Label_2") + ) + ) + ) + ) + ) + .withSecurity(auth) + + lazy val validateScenarioLabelsEndpoint + : SecuredEndpoint[ScenarioLabelsValidationRequestDto, Unit, ScenarioLabelsValidationResponseDto, Any] = + baseNuApiEndpoint + .summary("Service providing scenario labels validation") + .tag("Scenario labels") + .post + .in("scenarioLabels" / "validation") + .in( + jsonBody[ScenarioLabelsValidationRequestDto] + .examples( + List( + Example.of( + summary = Some("List of valid scenario labels"), + value = ScenarioLabelsValidationRequestDto( + labels = List("Label_1", "Label_2") + ) + ), + Example.of( + summary = Some("List of scenario labels with invalid one"), + value = ScenarioLabelsValidationRequestDto( + labels = List("Label_1", "Label_2", "Label 3") + ) + ) + ) + ) + ) + .out( + statusCode(Ok).and( + jsonBody[ScenarioLabelsValidationResponseDto] + .examples( + List( + Example.of( + summary = Some("Validation response with no errors"), + value = ScenarioLabelsValidationResponseDto( + validationErrors = List.empty + ) + ), + Example.of( + summary = Some("Validation response with errors"), + value = ScenarioLabelsValidationResponseDto( + validationErrors = List( + ValidationError( + label = "Label 3", + messages = List("Scenario label can contain only alphanumeric characters, '-' and '_'") + ) + ) + ) + ) + ) + ) + ) + ) + .withSecurity(auth) + +} + +object ScenarioLabelsApiEndpoints { + + object Dtos { + @derive(encoder, decoder, schema) + final case class ScenarioLabels(labels: List[String]) + + @derive(encoder, decoder, schema) + final case class ScenarioLabelsValidationRequestDto(labels: List[String]) + + @derive(encoder, decoder, schema) + final case class ScenarioLabelsValidationResponseDto(validationErrors: List[ValidationError]) + + @derive(encoder, decoder, schema) + final case class ValidationError(label: String, messages: List[String]) + } + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/utils/ScenarioDetailsOps.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/utils/ScenarioDetailsOps.scala new file mode 100644 index 00000000000..ce6d51b4e61 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/utils/ScenarioDetailsOps.scala @@ -0,0 +1,16 @@ +package pl.touk.nussknacker.ui.api.utils + +import pl.touk.nussknacker.restmodel.scenariodetails.ScenarioWithDetails +import pl.touk.nussknacker.ui.process.label.ScenarioLabel + +object ScenarioDetailsOps { + + implicit class ScenarioWithDetailsOps(val details: ScenarioWithDetails) extends AnyVal { + + def scenarioLabels: List[ScenarioLabel] = { + details.labels.map(ScenarioLabel.apply) + } + + } + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala index 56f9b8d2e78..c219bd06958 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala @@ -19,6 +19,7 @@ final case class FeatureTogglesConfig( environmentAlert: Option[EnvironmentAlert], commentSettings: Option[CommentSettings], deploymentCommentSettings: Option[DeploymentCommentSettings], + scenarioLabelConfig: Option[ScenarioLabelConfig], scenarioStateTimeout: Option[FiniteDuration], surveySettings: Option[SurveySettings], tabs: Option[List[TopTab]], @@ -45,6 +46,7 @@ object FeatureTogglesConfig extends LazyLogging { val remoteEnvironment = parseOptionalConfig[HttpRemoteEnvironmentConfig](config, "secondaryEnvironment") val commentSettings = parseOptionalConfig[CommentSettings](config, "commentSettings") val deploymentCommentSettings = parseDeploymentCommentSettings(config) + val scenarioLabelSettings = ScenarioLabelConfig.create(config) val scenarioStateTimeout = parseOptionalConfig[FiniteDuration](config, "scenarioStateTimeout") val surveySettings = parseOptionalConfig[SurveySettings](config, "surveySettings") @@ -61,6 +63,7 @@ object FeatureTogglesConfig extends LazyLogging { counts = counts, commentSettings = commentSettings, deploymentCommentSettings = deploymentCommentSettings, + scenarioLabelConfig = scenarioLabelSettings, scenarioStateTimeout = scenarioStateTimeout, surveySettings = surveySettings, tabs = tabs, diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/ScenarioLabelConfig.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/ScenarioLabelConfig.scala new file mode 100644 index 00000000000..98092c8395f --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/ScenarioLabelConfig.scala @@ -0,0 +1,61 @@ +package pl.touk.nussknacker.ui.config + +import cats.implicits._ +import com.typesafe.config.Config + +import scala.jdk.CollectionConverters._ +import scala.util.Try +import scala.util.matching.Regex + +final case class ScenarioLabelConfig private (validationRules: List[ScenarioLabelConfig.ValidationRule]) + +object ScenarioLabelConfig { + + final case class ValidationRule(validationRegex: Regex, message: String) { + def messageWithLabel(label: String): String = + message.replaceAll(s"""\\{label\\}""", label) + } + + private[config] def create(config: Config): Option[ScenarioLabelConfig] = { + val rootPath = "scenarioLabelSettings" + if (config.hasPath(rootPath)) { + val settingConfig = config.getConfig(rootPath) + createValidationRules(settingConfig) match { + case Right(rules) => Some(new ScenarioLabelConfig(rules)) + case Left(error) => + throw new IllegalArgumentException( + s"Invalid configuration for '$rootPath'", + error + ) + } + } else { + None + } + } + + private def createValidationRules(settingConfig: Config) = { + settingConfig + .getConfigList("validationRules") + .asScala + .toList + .traverse(createRule) + } + + private def createRule(config: Config): Either[ScenarioLabelValidationRuleError, ValidationRule] = { + val validationPattern = config.getString("validationPattern") + + Try(validationPattern.r).toEither + .leftMap((ex: Throwable) => + ScenarioLabelValidationRuleError(s"Incorrect validationPattern: $validationPattern", ex) + ) + .map { regex: Regex => + ValidationRule( + validationRegex = regex, + message = config.getString("validationMessage") + ) + } + } + + private final case class ScenarioLabelValidationRuleError(message: String, cause: Throwable) + extends Exception(message, cause) +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala index eec3241313c..a2afb3f5c59 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala @@ -10,7 +10,7 @@ trait NuTables with ProcessVersionEntityFactory with EnvironmentsEntityFactory with ProcessActionEntityFactory - with TagsEntityFactory + with ScenarioLabelsEntityFactory with AttachmentEntityFactory with DeploymentEntityFactory { protected val profile: JdbcProfile diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/ScenarioLabelsEntityFactory.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/ScenarioLabelsEntityFactory.scala new file mode 100644 index 00000000000..00864a4c3e9 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/ScenarioLabelsEntityFactory.scala @@ -0,0 +1,37 @@ +package pl.touk.nussknacker.ui.db.entity + +import pl.touk.nussknacker.engine.api.process.ProcessId +import slick.lifted.{TableQuery => LTableQuery} +import slick.sql.SqlProfile.ColumnOption.NotNull + +trait ScenarioLabelsEntityFactory extends BaseEntityFactory { + + import profile.api._ + + val processesTable: LTableQuery[ProcessEntityFactory#ProcessEntity] + + class ScenarioLabelsEntity(tag: Tag) extends Table[ScenarioLabelEntityData](tag, "scenario_labels") { + + def label = column[String]("label") + + def scenarioId = column[ProcessId]("scenario_id", NotNull) + + def * = (label, scenarioId) <> (ScenarioLabelEntityData.apply _ tupled, ScenarioLabelEntityData.unapply) + + def pk = primaryKey("pk_scenario_label", (label, scenarioId)) + + def scenario = foreignKey("label_scenario_fk", scenarioId, processesTable)( + _.id, + onUpdate = ForeignKeyAction.Cascade, + onDelete = ForeignKeyAction.Cascade + ) + + } + + val labelsTable: LTableQuery[ScenarioLabelsEntityFactory#ScenarioLabelsEntity] = LTableQuery( + new ScenarioLabelsEntity(_) + ) + +} + +final case class ScenarioLabelEntityData(name: String, scenarioId: ProcessId) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/TagsEntityFactory.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/TagsEntityFactory.scala index 56b955ea99f..13dd25c809b 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/TagsEntityFactory.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/TagsEntityFactory.scala @@ -4,6 +4,8 @@ import pl.touk.nussknacker.engine.api.process.ProcessId import slick.lifted.{TableQuery => LTableQuery} import slick.sql.SqlProfile.ColumnOption.NotNull +// TODO tags table is unattached from code, but is left for backward compatibility in case of version rollback +// table can be dropped after next version release trait TagsEntityFactory extends BaseEntityFactory { import profile.api._ diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/initialization/Initialization.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/initialization/Initialization.scala index 6483a67b138..520ad354d98 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/initialization/Initialization.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/initialization/Initialization.scala @@ -29,9 +29,11 @@ object Initialization { db: DbRef, fetchingRepository: DBFetchingProcessRepository[DB], commentRepository: CommentRepository, + scenarioLabelsRepository: ScenarioLabelsRepository, environment: String )(implicit ec: ExecutionContext): Unit = { - val processRepository = new DBProcessRepository(db, commentRepository, migrations.mapValues(_.version)) + val processRepository = + new DBProcessRepository(db, commentRepository, scenarioLabelsRepository, migrations.mapValues(_.version)) val operations: List[InitialOperation] = List( new EnvironmentInsert(environment, db), diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrateScenarioData.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrateScenarioData.scala index 19dbcfd2de0..922145faba2 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrateScenarioData.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrateScenarioData.scala @@ -6,7 +6,8 @@ import pl.touk.nussknacker.engine.api.process.ProcessName import pl.touk.nussknacker.engine.deployment.EngineSetupName import pl.touk.nussknacker.ui.api.description.MigrationApiEndpoints.Dtos.{ MigrateScenarioRequestDto, - MigrateScenarioRequestDtoV1 + MigrateScenarioRequestDtoV1, + MigrateScenarioRequestDtoV2 } import pl.touk.nussknacker.ui.migrations.MigrationService.MigrationError import pl.touk.nussknacker.ui.util.VersionedData @@ -15,7 +16,7 @@ sealed trait MigrateScenarioData extends VersionedData object MigrateScenarioData { - type CurrentMigrateScenarioData = MigrateScenarioDataV1 + type CurrentMigrateScenarioData = MigrateScenarioDataV2 def toDomain(migrateScenarioRequestDto: MigrateScenarioRequestDto): Either[MigrationError, MigrateScenarioData] = migrateScenarioRequestDto match { @@ -42,13 +43,14 @@ object MigrateScenarioData { isFragment ) ) - /* case MigrateScenarioRequestDtoV2( + case MigrateScenarioRequestDtoV2( 2, sourceEnvironmentId, remoteUserName, processingMode, engineSetupName, processCategory, + scenarioLabels, scenarioGraph, processName, isFragment @@ -60,11 +62,12 @@ object MigrateScenarioData { processingMode, engineSetupName, processCategory, + scenarioLabels, scenarioGraph, processName, isFragment ) - )*/ + ) case _ => Left(MigrationError.CannotTransformMigrateScenarioRequestIntoMigrationDomain) } @@ -91,12 +94,13 @@ object MigrateScenarioData { processName, isFragment ) - /* case dataV2 @ MigrateScenarioDataV2( + case dataV2 @ MigrateScenarioDataV2( sourceEnvironmentId, remoteUserName, processingMode, engineSetupName, processCategory, + scenarioLabels, scenarioGraph, processName, isFragment @@ -108,10 +112,11 @@ object MigrateScenarioData { processingMode, engineSetupName, processCategory, + scenarioLabels, scenarioGraph, processName, isFragment - )*/ + ) } } @@ -129,11 +134,25 @@ final case class MigrateScenarioDataV1( override val currentVersion: Int = 1 } +final case class MigrateScenarioDataV2( + sourceEnvironmentId: String, + remoteUserName: String, + processingMode: ProcessingMode, + engineSetupName: EngineSetupName, + processCategory: String, + scenarioLabels: List[String], + scenarioGraph: ScenarioGraph, + processName: ProcessName, + isFragment: Boolean, +) extends MigrateScenarioData { + override val currentVersion: Int = 2 +} + /* NOTE TO DEVELOPER: -When implementing MigrateScenarioRequestDtoV2: +When implementing MigrateScenarioRequestDtoV3: 1. Review and update the parameter types and names if necessary. 2. Consider backward compatibility with existing code. @@ -143,7 +162,7 @@ When implementing MigrateScenarioRequestDtoV2: Remember to uncomment the class definition after implementation. -final case class MigrateScenarioDataV2( +final case class MigrateScenarioDataV3( sourceEnvironmentId: String, remoteUserName: String, processingMode: ProcessingMode, @@ -153,5 +172,5 @@ final case class MigrateScenarioDataV2( processName: ProcessName, isFragment: Boolean, ) extends MigrateScenarioData { - override val currentVersion: Int = 2 + override val currentVersion: Int = 3 }*/ diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrationApiAdapters.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrationApiAdapters.scala index 56c968279d6..ac00796954d 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrationApiAdapters.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrationApiAdapters.scala @@ -18,8 +18,9 @@ object MigrationApiAdapters { 6. Update StandardRemoteEnvironmentSpec, especially the Migrate endpoint mock Remember to uncomment the case object after implementation. + */ - case object MigrationApiAdapterV1ToV2 extends ApiAdapter[MigrateScenarioData] { + case object MigrationApiAdapterV1ToV2 extends ApiAdapter[MigrateScenarioData] { override def liftVersion: MigrateScenarioData => MigrateScenarioData = { case v1: MigrateScenarioDataV1 => @@ -29,6 +30,7 @@ object MigrationApiAdapters { processingMode = v1.processingMode, engineSetupName = v1.engineSetupName, processCategory = v1.processCategory, + scenarioLabels = List.empty, scenarioGraph = v1.scenarioGraph, processName = v1.processName, isFragment = v1.isFragment @@ -51,8 +53,8 @@ object MigrationApiAdapters { case _ => throw new IllegalStateException("Expecting another value object") } - }*/ + } - val adapters: Map[Int, ApiAdapter[MigrateScenarioData]] = Map.empty + val adapters: Map[Int, ApiAdapter[MigrateScenarioData]] = Map(1 -> MigrationApiAdapterV1ToV2) } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrationService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrationService.scala index 72067429fab..1868ca69eda 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrationService.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/migrations/MigrationService.scala @@ -21,6 +21,7 @@ import pl.touk.nussknacker.ui.process.ProcessService.{ GetScenarioWithDetailsOptions, UpdateScenarioCommand } +import pl.touk.nussknacker.ui.process.label.ScenarioLabel import pl.touk.nussknacker.ui.process.migrate.{MigrationToArchivedError, MigrationValidationError} import pl.touk.nussknacker.ui.process.processingtype.ScenarioParametersService import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider @@ -84,9 +85,10 @@ class MigrationService( migrateScenarioData.processCategory, migrateScenarioData.engineSetupName ) - val scenarioGraph = migrateScenarioData.scenarioGraph - val processName = migrateScenarioData.processName - val isFragment = migrateScenarioData.isFragment + val scenarioGraph = migrateScenarioData.scenarioGraph + val processName = migrateScenarioData.processName + val isFragment = migrateScenarioData.isFragment + val scenarioLabels = migrateScenarioData.scenarioLabels.map(ScenarioLabel.apply) val forwardedUsernameO = if (passUsernameInMigration) Some(RemoteUserName(migrateScenarioData.remoteUserName)) else None val updateProcessComment = { @@ -97,8 +99,14 @@ class MigrationService( UpdateProcessComment(s"Scenario migrated from $sourceEnvironmentId by Unknown user") } } + val updateScenarioCommand = - UpdateScenarioCommand(scenarioGraph, Some(updateProcessComment), forwardedUsernameO) + UpdateScenarioCommand( + scenarioGraph = scenarioGraph, + comment = Some(updateProcessComment), + scenarioLabels = Some(scenarioLabels.map(_.value)), + forwardedUserName = forwardedUsernameO + ) val processingTypeValidated = scenarioParametersService.combined.queryProcessingTypeWithWritePermission( Some(parameters.category), @@ -109,7 +117,13 @@ class MigrationService( val result: EitherT[Future, MigrationError, Unit] = for { processingType <- EitherT.fromEither[Future](processingTypeValidated.toEither).leftMap(MigrationError.from(_)) validationResult <- - validateProcessingTypeAndUIProcessResolver(scenarioGraph, processName, isFragment, processingType) + validateProcessingTypeAndUIProcessResolver( + scenarioGraph, + processName, + isFragment, + scenarioLabels, + processingType + ) _ <- checkForValidationErrors(validationResult) _ <- checkOrCreateAndCheckArchivedProcess( processName, @@ -209,6 +223,7 @@ class MigrationService( scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean, + labels: List[ScenarioLabel], processingType: ProcessingType )(implicit loggedUser: LoggedUser) = { EitherT @@ -218,7 +233,7 @@ class MigrationService( case Left(e) => Left(e) case Right(uiProcessResolver) => FatalValidationError.renderNotAllowedAsError( - uiProcessResolver.validateBeforeUiResolving(scenarioGraph, processName, isFragment) + uiProcessResolver.validateBeforeUiResolving(scenarioGraph, processName, isFragment, labels) ) } ) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/ProcessService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/ProcessService.scala index 3fb5728a0f3..2deecf2877c 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/ProcessService.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/ProcessService.scala @@ -22,6 +22,7 @@ import pl.touk.nussknacker.ui.api.ProcessesResources.ProcessUnmarshallingError import pl.touk.nussknacker.ui.process.ProcessService._ import pl.touk.nussknacker.ui.process.ScenarioWithDetailsConversions._ import pl.touk.nussknacker.ui.process.exception.{ProcessIllegalAction, ProcessValidationError} +import pl.touk.nussknacker.ui.process.label.ScenarioLabel import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter import pl.touk.nussknacker.ui.process.processingtype.ScenarioParametersService import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider @@ -59,6 +60,7 @@ object ProcessService { @JsonCodec final case class UpdateScenarioCommand( scenarioGraph: ScenarioGraph, comment: Option[UpdateProcessComment], + scenarioLabels: Option[List[String]], forwardedUserName: Option[RemoteUserName] ) @@ -297,7 +299,12 @@ class DBProcessService( ScenarioWithDetailsConversions.fromEntity( entity.mapScenario { canonical: CanonicalProcess => val processResolver = processResolverByProcessingType.forProcessingTypeUnsafe(entity.processingType) - processResolver.validateAndReverseResolve(canonical, entity.name, entity.isFragment) + processResolver.validateAndReverseResolve( + canonical, + entity.name, + entity.isFragment, + entity.scenarioLabels.map(ScenarioLabel.apply) + ) }, parameters ) @@ -393,15 +400,22 @@ class DBProcessService( ): Future[UpdateProcessResponse] = withNotArchivedProcess(processIdWithName, "Can't update graph archived scenario.") { details => val processResolver = processResolverByProcessingType.forProcessingTypeUnsafe(details.processingType) + val scenarioLabels = action.scenarioLabels.getOrElse(List.empty).map(ScenarioLabel.apply) val validation = FatalValidationError.saveNotAllowedAsError( - processResolver.validateBeforeUiResolving(action.scenarioGraph, details.name, details.isFragment) + processResolver.validateBeforeUiResolving( + action.scenarioGraph, + details.name, + details.isFragment, + scenarioLabels + ) ) val substituted = processResolver.resolveExpressions(action.scenarioGraph, details.name, validation.typingInfo) val updateProcessAction = UpdateProcessAction( - processIdWithName.id, - substituted, - action.comment, + processId = processIdWithName.id, + canonicalProcess = substituted, + comment = action.comment, + labels = scenarioLabels, increaseVersionWhenJsonNotChanged = false, forwardedUserName = action.forwardedUserName ) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/ScenarioWithDetailsConversions.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/ScenarioWithDetailsConversions.scala index c327b86c028..d0e327f8ca9 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/ScenarioWithDetailsConversions.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/ScenarioWithDetailsConversions.scala @@ -43,7 +43,7 @@ object ScenarioWithDetailsConversions { modifiedBy = details.modifiedBy, createdAt = details.createdAt, createdBy = details.createdBy, - tags = details.tags, + labels = details.scenarioLabels, lastDeployedAction = details.lastDeployedAction, lastStateAction = details.lastStateAction, lastAction = details.lastAction, @@ -80,7 +80,7 @@ object ScenarioWithDetailsConversions { modifiedBy = scenarioWithDetails.modifiedBy, createdAt = scenarioWithDetails.createdAt, createdBy = scenarioWithDetails.createdBy, - tags = scenarioWithDetails.tags, + scenarioLabels = scenarioWithDetails.labels, lastDeployedAction = scenarioWithDetails.lastDeployedAction, lastStateAction = scenarioWithDetails.lastStateAction, lastAction = scenarioWithDetails.lastAction, diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/deployment/DefaultProcessingTypeDeployedScenariosProvider.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/deployment/DefaultProcessingTypeDeployedScenariosProvider.scala index 95d0af8567e..29da6fc6dd9 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/deployment/DefaultProcessingTypeDeployedScenariosProvider.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/deployment/DefaultProcessingTypeDeployedScenariosProvider.scala @@ -85,10 +85,12 @@ object DefaultProcessingTypeDeployedScenariosProvider { val dumbModelInfoProvier = ProcessingTypeDataProvider.withEmptyCombinedData( Map(processingType -> ValueWithRestriction.anyUser(Map.empty[String, String])) ) - val commentRepository = new CommentRepository(dbRef) - val actionRepository = new DbProcessActionRepository(dbRef, commentRepository, dumbModelInfoProvier) - val processRepository = DBFetchingProcessRepository.create(dbRef, actionRepository) - val futureProcessRepository = DBFetchingProcessRepository.createFutureRepository(dbRef, actionRepository) + val commentRepository = new CommentRepository(dbRef) + val actionRepository = new DbProcessActionRepository(dbRef, commentRepository, dumbModelInfoProvier) + val scenarioLabelsRepository = new ScenarioLabelsRepository(dbRef) + val processRepository = DBFetchingProcessRepository.create(dbRef, actionRepository, scenarioLabelsRepository) + val futureProcessRepository = + DBFetchingProcessRepository.createFutureRepository(dbRef, actionRepository, scenarioLabelsRepository) new DefaultProcessingTypeDeployedScenariosProvider( processRepository, dbioRunner, diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/label/ScenarioLabel.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/label/ScenarioLabel.scala new file mode 100644 index 00000000000..693364c9302 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/label/ScenarioLabel.scala @@ -0,0 +1,3 @@ +package pl.touk.nussknacker.ui.process.label + +final case class ScenarioLabel(value: String) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/label/ScenarioLabelsService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/label/ScenarioLabelsService.scala new file mode 100644 index 00000000000..1e4b6659fd7 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/label/ScenarioLabelsService.scala @@ -0,0 +1,29 @@ +package pl.touk.nussknacker.ui.process.label + +import cats.data.ValidatedNel +import pl.touk.nussknacker.ui.process.repository.{DBIOActionRunner, ScenarioLabelsRepository} +import pl.touk.nussknacker.ui.security.api.LoggedUser +import pl.touk.nussknacker.ui.validation.ScenarioLabelsValidator + +import scala.concurrent.{ExecutionContext, Future} + +class ScenarioLabelsService( + scenarioLabelsRepository: ScenarioLabelsRepository, + scenarioLabelsValidator: ScenarioLabelsValidator, + dbioRunner: DBIOActionRunner +)( + implicit ec: ExecutionContext +) { + + def readLabels(loggedUser: LoggedUser): Future[List[String]] = { + dbioRunner + .run(scenarioLabelsRepository.getLabels(loggedUser)) + .map { + _.map(_.value).sorted + } + } + + def validatedScenarioLabels(labels: List[String]): ValidatedNel[ScenarioLabelsValidator.ValidationError, Unit] = + scenarioLabelsValidator.validate(labels.map(ScenarioLabel.apply)) + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/ProcessModelMigrator.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/ProcessModelMigrator.scala index 9c6fac24904..e3eadee97d7 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/ProcessModelMigrator.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/ProcessModelMigrator.scala @@ -14,6 +14,7 @@ final case class MigrationResult(process: CanonicalProcess, migrationsApplied: L processId = processId, canonicalProcess = process, comment = Option(migrationsApplied).filter(_.nonEmpty).map(MigrationComment), + labels = List.empty, increaseVersionWhenJsonNotChanged = true, forwardedUserName = None ) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/RemoteEnvironment.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/RemoteEnvironment.scala index 368ccb94006..ccc01724797 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/RemoteEnvironment.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/RemoteEnvironment.scala @@ -21,7 +21,12 @@ import pl.touk.nussknacker.restmodel.scenariodetails.ScenarioWithDetailsForMigra import pl.touk.nussknacker.restmodel.validation.ValidationResults.ValidationErrors import pl.touk.nussknacker.ui.NuDesignerError.XError import pl.touk.nussknacker.ui.api.description.MigrationApiEndpoints.Dtos.ApiVersion -import pl.touk.nussknacker.ui.migrations.{MigrateScenarioData, MigrateScenarioDataV1, MigrationApiAdapterService} +import pl.touk.nussknacker.ui.migrations.{ + MigrateScenarioData, + MigrateScenarioDataV1, + MigrateScenarioDataV2, + MigrationApiAdapterService +} import pl.touk.nussknacker.ui.security.api.LoggedUser import pl.touk.nussknacker.ui.util.ScenarioGraphComparator.Difference import pl.touk.nussknacker.ui.util.{ApiAdapterServiceError, OutOfRangeAdapterRequestError, ScenarioGraphComparator} @@ -52,6 +57,7 @@ trait RemoteEnvironment { processingMode: ProcessingMode, engineSetupName: EngineSetupName, processCategory: String, + scenarioLabels: List[String], scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean @@ -207,6 +213,7 @@ trait StandardRemoteEnvironment extends FailFastCirceSupport with RemoteEnvironm processingMode: ProcessingMode, engineSetupName: EngineSetupName, processCategory: String, + scenarioLabels: List[String], scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean @@ -216,12 +223,13 @@ trait StandardRemoteEnvironment extends FailFastCirceSupport with RemoteEnvironm remoteScenarioDescriptionVersion <- fetchRemoteMigrationScenarioDescriptionVersion localScenarioDescriptionVersion = migrationApiAdapterService.getCurrentApiVersion migrateScenarioRequest: MigrateScenarioData = - MigrateScenarioDataV1( + MigrateScenarioDataV2( environmentId, loggedUser.username, processingMode, engineSetupName, processCategory, + scenarioLabels, scenarioGraph, processName, isFragment diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/TestModelMigrations.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/TestModelMigrations.scala index f8c186b2b1d..d35abcb9650 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/TestModelMigrations.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/migrate/TestModelMigrations.scala @@ -14,6 +14,7 @@ import pl.touk.nussknacker.restmodel.validation.ValidationResults.{ ValidationWarnings } import pl.touk.nussknacker.ui.process.fragment.{FragmentRepository, FragmentResolver} +import pl.touk.nussknacker.ui.process.label.ScenarioLabel import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider import pl.touk.nussknacker.ui.security.api.LoggedUser @@ -48,7 +49,12 @@ class TestModelMigrations( val validationResult = validator .forProcessingTypeUnsafe(migrationDetails.processingType) - .validate(migrationDetails.newScenarioGraph, migrationDetails.processName, migrationDetails.isFragment) + .validate( + migrationDetails.newScenarioGraph, + migrationDetails.processName, + migrationDetails.isFragment, + migrationDetails.labels + ) val newErrors = extractNewErrors(migrationDetails.oldProcessErrors, validationResult) TestMigrationResult( migrationDetails.processName, @@ -76,6 +82,7 @@ class TestModelMigrations( scenarioWithDetails.name, scenarioWithDetails.processingType, scenarioWithDetails.isFragment, + scenarioWithDetails.labels.map(ScenarioLabel.apply), scenarioGraph, scenarioWithDetails.validationResultUnsafe ) @@ -153,6 +160,7 @@ private final case class MigratedProcessDetails( processName: ProcessName, processingType: ProcessingType, isFragment: Boolean, + labels: List[ScenarioLabel], newScenarioGraph: ScenarioGraph, oldProcessErrors: ValidationResult ) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/provider/ReloadableProcessingTypeDataProvider.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/provider/ReloadableProcessingTypeDataProvider.scala index 9633357f21b..32fa266375e 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/provider/ReloadableProcessingTypeDataProvider.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/provider/ReloadableProcessingTypeDataProvider.scala @@ -19,14 +19,15 @@ import pl.touk.nussknacker.ui.security.api.NussknackerInternalUser * close we want to catch exception and try to proceed, but during creation it can be a bit tricky... */ class ReloadableProcessingTypeDataProvider( - loadMethod: IO[ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData]] - ) extends ProcessingTypeDataProvider[ProcessingTypeData, CombinedProcessingTypeData] - with LazyLogging { + loadMethod: IO[ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData]] +) extends ProcessingTypeDataProvider[ProcessingTypeData, CombinedProcessingTypeData] + with LazyLogging { // We init state with dumb value instead of calling loadMethod() to avoid problems with dependency injection cycle - see NusskanckerDefaultAppRouter.create private var stateValue: ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData] = emptyState - override private[processingtype] def state: ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData] = { + override private[processingtype] def state + : ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData] = { synchronized { stateValue } @@ -35,17 +36,22 @@ class ReloadableProcessingTypeDataProvider( def reloadAll(): IO[Unit] = synchronized { for { beforeReload <- IO.pure(stateValue) - _ <- IO(logger.info( - s"Closing state with old processing types [${beforeReload.all.keys.toList.sorted.mkString(", ")}] and identity [${beforeReload.stateIdentity}]" - )) + _ <- IO( + logger.info( + s"Closing state with old processing types [${beforeReload.all.keys.toList.sorted + .mkString(", ")}] and identity [${beforeReload.stateIdentity}]" + ) + ) _ <- close(beforeReload) _ <- IO( logger.info("Reloading processing type data...") ) newState <- loadMethod - _ <- IO(logger.info( - s"New state with processing types [${state.all.keys.toList.sorted.mkString(", ")}] and identity [${state.stateIdentity}] reloaded finished" - )) + _ <- IO( + logger.info( + s"New state with processing types [${state.all.keys.toList.sorted.mkString(", ")}] and identity [${state.stateIdentity}] reloaded finished" + ) + ) } yield { stateValue = newState } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepository.scala index d21bcbacbc3..2d62ce7cdd2 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepository.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepository.scala @@ -9,6 +9,7 @@ import pl.touk.nussknacker.engine.api.deployment.{ProcessAction, ProcessActionSt import pl.touk.nussknacker.engine.api.process._ import pl.touk.nussknacker.ui.db.DbRef import pl.touk.nussknacker.ui.db.entity._ +import pl.touk.nussknacker.ui.process.label.ScenarioLabel import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter import pl.touk.nussknacker.ui.process.repository.ProcessDBQueryRepository.ProcessNotFoundError import pl.touk.nussknacker.ui.process.{ScenarioQuery, repository} @@ -19,13 +20,21 @@ import scala.language.higherKinds object DBFetchingProcessRepository { - def create(dbRef: DbRef, actionRepository: ProcessActionRepository)(implicit ec: ExecutionContext) = - new DBFetchingProcessRepository[DB](dbRef, actionRepository) with DbioRepository + def create( + dbRef: DbRef, + actionRepository: ProcessActionRepository, + scenarioLabelsRepository: ScenarioLabelsRepository + )(implicit ec: ExecutionContext) = + new DBFetchingProcessRepository[DB](dbRef, actionRepository, scenarioLabelsRepository) with DbioRepository - def createFutureRepository(dbRef: DbRef, actionRepository: ProcessActionRepository)( + def createFutureRepository( + dbRef: DbRef, + actionRepository: ProcessActionRepository, + scenarioLabelsRepository: ScenarioLabelsRepository + )( implicit ec: ExecutionContext ) = - new DBFetchingProcessRepository[Future](dbRef, actionRepository) with BasicRepository + new DBFetchingProcessRepository[Future](dbRef, actionRepository, scenarioLabelsRepository) with BasicRepository } @@ -34,7 +43,8 @@ object DBFetchingProcessRepository { // to the resource on the services side abstract class DBFetchingProcessRepository[F[_]: Monad]( protected val dbRef: DbRef, - actionRepository: ProcessActionRepository + actionRepository: ProcessActionRepository, + scenarioLabelsRepository: ScenarioLabelsRepository, )(protected implicit val ec: ExecutionContext) extends FetchingProcessRepository[F] with LazyLogging { @@ -90,18 +100,19 @@ abstract class DBFetchingProcessRepository[F[_]: Monad]( }) latestProcesses <- fetchLatestProcessesQuery(query, lastDeployedActionPerProcess.keySet, isDeployed).result + labels <- scenarioLabelsRepository.getLabels } yield latestProcesses .map { case ((_, processVersion), process) => createFullDetails( - process, - processVersion, - lastActionPerProcess.get(process.id), - lastStateActionPerProcess.get(process.id), - lastDeployedActionPerProcess.get(process.id), + process = process, + processVersion = processVersion, + lastActionData = lastActionPerProcess.get(process.id), + lastStateActionData = lastStateActionPerProcess.get(process.id), + lastDeployedActionData = lastDeployedActionPerProcess.get(process.id), isLatestVersion = true, - // For optimisation reasons we don't return history and tags when querying for list of processes - None, - None + labels = labels.getOrElse(process.id, List.empty), + // For optimisation reasons we don't return history when querying for list of processes + history = None ) }).map(_.toList) } @@ -183,7 +194,7 @@ abstract class DBFetchingProcessRepository[F[_]: Monad]( fetchProcessLatestVersionsQuery(id)(ScenarioShapeFetchStrategy.NotFetch).result ) actions <- OptionT.liftF[DB, List[ProcessAction]](actionRepository.getFinishedProcessActions(id, None)) - tags <- OptionT.liftF[DB, Seq[TagsEntityData]](tagsTable.filter(_.processId === process.id).result) + labels <- OptionT.liftF(scenarioLabelsRepository.getLabels(id)) } yield createFullDetails( process = process, processVersion = processVersion, @@ -197,7 +208,7 @@ abstract class DBFetchingProcessRepository[F[_]: Monad]( action.actionName == ScenarioActionName.Deploy && action.state == ProcessActionState.Finished ), isLatestVersion = isLatestVersion, - tags = Some(tags), + labels = labels, history = Some( processVersions.map(v => ProcessDBQueryRepository.toProcessVersion(v, actions.filter(p => p.processVersionId == v.id)) @@ -213,7 +224,7 @@ abstract class DBFetchingProcessRepository[F[_]: Monad]( lastStateActionData: Option[ProcessAction], lastDeployedActionData: Option[ProcessAction], isLatestVersion: Boolean, - tags: Option[Seq[TagsEntityData]], + labels: List[ScenarioLabel], history: Option[Seq[ScenarioVersion]] ): ScenarioWithDetailsEntity[PS] = { repository.ScenarioWithDetailsEntity[PS]( @@ -229,7 +240,7 @@ abstract class DBFetchingProcessRepository[F[_]: Monad]( lastAction = lastActionData, lastStateAction = lastStateActionData, lastDeployedAction = lastDeployedActionData, - tags = tags.map(_.map(_.name).toList), + scenarioLabels = labels.map(_.value), modificationDate = processVersion.createDate.toInstant, modifiedAt = processVersion.createDate.toInstant, modifiedBy = processVersion.user, diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ProcessRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ProcessRepository.scala index 78f2bbe1ef4..bf0bdaa63e9 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ProcessRepository.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ProcessRepository.scala @@ -11,6 +11,7 @@ import pl.touk.nussknacker.engine.migration.ProcessMigrations import pl.touk.nussknacker.ui.db.entity.{ProcessEntityData, ProcessVersionEntityData} import pl.touk.nussknacker.ui.db.{DbRef, NuTables} import pl.touk.nussknacker.ui.listener.Comment +import pl.touk.nussknacker.ui.process.label.ScenarioLabel import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider import pl.touk.nussknacker.ui.process.repository.ProcessDBQueryRepository._ import pl.touk.nussknacker.ui.process.repository.ProcessRepository.{ @@ -44,9 +45,10 @@ object ProcessRepository { def create( dbRef: DbRef, commentRepository: CommentRepository, + scenarioLabelsRepository: ScenarioLabelsRepository, migrations: ProcessingTypeDataProvider[ProcessMigrations, _] ): DBProcessRepository = - new DBProcessRepository(dbRef, commentRepository, migrations.mapValues(_.version)) + new DBProcessRepository(dbRef, commentRepository, scenarioLabelsRepository, migrations.mapValues(_.version)) final case class CreateProcessAction( processName: ProcessName, @@ -61,6 +63,7 @@ object ProcessRepository { private val processId: ProcessId, canonicalProcess: CanonicalProcess, comment: Option[Comment], + labels: List[ScenarioLabel], increaseVersionWhenJsonNotChanged: Boolean, forwardedUserName: Option[RemoteUserName] ) { @@ -89,6 +92,7 @@ trait ProcessRepository[F[_]] { class DBProcessRepository( protected val dbRef: DbRef, commentRepository: CommentRepository, + scenarioLabelsRepository: ScenarioLabelsRepository, modelVersion: ProcessingTypeDataProvider[Int, _] ) extends ProcessRepository[DB] with NuTables @@ -158,19 +162,26 @@ class DBProcessRepository( .sequence } - updateProcessInternal( - updateProcessAction.id, - updateProcessAction.canonicalProcess, - updateProcessAction.increaseVersionWhenJsonNotChanged, - userName - ).flatMap { - // Comment should be added via ProcessService not to mix this repository responsibility. - case updateProcessRes @ ProcessUpdated(processId, _, Some(newVersion)) => - addNewCommentToVersion(processId, newVersion).map(_ => updateProcessRes) - case updateProcessRes @ ProcessUpdated(processId, Some(oldVersion), _) => - addNewCommentToVersion(processId, oldVersion).map(_ => updateProcessRes) - case a => DBIO.successful(a) - } + for { + updateProcessRes <- updateProcessInternal( + updateProcessAction.id, + updateProcessAction.canonicalProcess, + updateProcessAction.increaseVersionWhenJsonNotChanged, + userName + ) + _ <- updateProcessRes match { + // Comment should be added via ProcessService not to mix this repository responsibility. + case updateProcessRes @ ProcessUpdated(processId, _, Some(newVersion)) => + addNewCommentToVersion(processId, newVersion).map(_ => updateProcessRes) + case updateProcessRes @ ProcessUpdated(processId, Some(oldVersion), _) => + addNewCommentToVersion(processId, oldVersion).map(_ => updateProcessRes) + case _ => dbMonad.unit + } + _ <- scenarioLabelsRepository.overwriteLabels( + updateProcessAction.id.id, + updateProcessAction.labels + ) + } yield updateProcessRes } private def updateProcessInternal( diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioLabelsRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioLabelsRepository.scala new file mode 100644 index 00000000000..fd95ceb90a6 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioLabelsRepository.scala @@ -0,0 +1,95 @@ +package pl.touk.nussknacker.ui.process.repository + +import cats.data.NonEmptyList +import db.util.DBIOActionInstances.{DB, _} +import pl.touk.nussknacker.engine.api.process.ProcessId +import pl.touk.nussknacker.security.Permission +import pl.touk.nussknacker.ui.db.entity.{ProcessEntityData, ProcessEntityFactory, ScenarioLabelEntityData} +import pl.touk.nussknacker.ui.db.{DbRef, NuTables} +import pl.touk.nussknacker.ui.process.label.ScenarioLabel +import pl.touk.nussknacker.ui.security.api.{AdminUser, CommonUser, ImpersonatedUser, LoggedUser, RealLoggedUser} +import slick.jdbc.JdbcProfile + +import scala.concurrent.ExecutionContext + +class ScenarioLabelsRepository(protected val dbRef: DbRef)(implicit ec: ExecutionContext) extends NuTables { + + override protected val profile: JdbcProfile = dbRef.profile + + private implicit val scenarioLabelOrdering: Ordering[ScenarioLabel] = Ordering.by(_.value) + + import profile.api._ + + def getLabels(processId: ProcessId): DB[List[ScenarioLabel]] = findLabels(processId).map(_.toList.sorted) + + def getLabels: DB[Map[ProcessId, List[ScenarioLabel]]] = { + labelsTable.result + .map { + _.groupBy(_.scenarioId).map { case (scenarioId, tagsEntities) => + (scenarioId, tagsEntities.map(toScenarioLabel).toList) + } + } + } + + def getLabels(loggedUser: LoggedUser): DB[List[ScenarioLabel]] = { + labelsByUser(loggedUser).result.map(_.map(ScenarioLabel.apply).toList) + } + + def overwriteLabels(scenarioId: ProcessId, scenarioLabels: List[ScenarioLabel]): DB[Unit] = + updateScenarioLabels(scenarioId, scenarioLabels) + + private def updateScenarioLabels(scenarioId: ProcessId, scenarioLabels: List[ScenarioLabel]): DBIO[Unit] = { + val newLabels = scenarioLabels.toSet + for { + existingLabels <- findLabels(scenarioId) + maybeLabelsToInsert = NonEmptyList.fromList((newLabels -- existingLabels).toList) + maybeLabelsToRemove = NonEmptyList.fromList((existingLabels -- newLabels).toList) + _: Unit <- maybeLabelsToInsert match { + case Some(labelsToRemove) => + (labelsTable ++= labelsToRemove.toList.map(label => ScenarioLabelEntityData(label.value, scenarioId))) + .map(_ => ()) + case None => dbMonad.unit + } + _: Unit <- maybeLabelsToRemove match { + case Some(labelsToRemove) => + labelsTable + .filter(_.scenarioId === scenarioId) + .filter(_.label.inSet(labelsToRemove.toList.map(_.value).toSet)) + .delete + .map(_ => ()) + case None => dbMonad.unit + } + } yield () + } + + private def findLabels(scenarioId: ProcessId) = + findLabelsCompiled(scenarioId).result.map(_.map(toScenarioLabel).toSet) + + private def findLabelsCompiled = + Compiled((scenarioId: Rep[ProcessId]) => labelsTable.filter(_.scenarioId === scenarioId)) + + private def toScenarioLabel(entity: ScenarioLabelEntityData) = ScenarioLabel(entity.name) + + private def labelsByUser(loggedUser: LoggedUser) = { + def getTableForUser(user: RealLoggedUser) = { + user match { + case user: CommonUser => + labelsTable + .join(scenarioIdsFor(user)) + .on(_.scenarioId === _) + .map(_._1.label) + .distinct + case _: AdminUser => + labelsTable.map(_.label).distinct + } + } + loggedUser match { + case user: RealLoggedUser => getTableForUser(user) + case user: ImpersonatedUser => getTableForUser(user.impersonatedUser) + } + } + + private def scenarioIdsFor(user: CommonUser) = + processesTable.filter(_.processCategory inSet user.categories(Permission.Read)).map(_.id) + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioWithDetailsEntity.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioWithDetailsEntity.scala index 54129aa1884..40f03da5dea 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioWithDetailsEntity.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioWithDetailsEntity.scala @@ -33,7 +33,7 @@ final case class ScenarioWithDetailsEntity[ScenarioShape]( modifiedBy: String, createdAt: Instant, createdBy: String, - tags: Option[List[String]], + scenarioLabels: List[String], lastDeployedAction: Option[ProcessAction], lastStateAction: Option[ ProcessAction 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 b5a85251ea1..5625def93d3 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 @@ -17,6 +17,7 @@ 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 @@ -32,19 +33,25 @@ class ScenarioTestService( testExecutorService: ScenarioTestExecutorService, ) extends LazyLogging { - def getTestingCapabilities(scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean)( + def getTestingCapabilities( + scenarioGraph: ScenarioGraph, + processName: ProcessName, + isFragment: Boolean, + labels: List[ScenarioLabel] + )( implicit user: LoggedUser ): TestingCapabilities = { - val canonical = toCanonicalProcess(scenarioGraph, processName, isFragment) + val canonical = toCanonicalProcess(scenarioGraph, processName, isFragment, labels) testInfoProvider.getTestingCapabilities(canonical) } def testParametersDefinition( scenarioGraph: ScenarioGraph, processName: ProcessName, - isFragment: Boolean + isFragment: Boolean, + labels: List[ScenarioLabel], )(implicit user: LoggedUser): List[UISourceParameters] = { - val canonical = toCanonicalProcess(scenarioGraph, processName, isFragment) + val canonical = toCanonicalProcess(scenarioGraph, processName, isFragment, labels) testInfoProvider .getTestParameters(canonical) .map { case (id, params) => UISourceParameters(id, params.map(DefinitionsService.createUIParameter)) } @@ -56,11 +63,12 @@ class ScenarioTestService( scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean, + labels: List[ScenarioLabel], testSampleSize: Int )( implicit user: LoggedUser ): Either[String, RawScenarioTestData] = { - val canonical = toCanonicalProcess(scenarioGraph, processName, isFragment) + val canonical = toCanonicalProcess(scenarioGraph, processName, isFragment, labels) for { _ <- Either.cond( @@ -77,13 +85,14 @@ class ScenarioTestService( idWithName: ProcessIdWithName, scenarioGraph: ScenarioGraph, isFragment: Boolean, + labels: List[ScenarioLabel], rawTestData: RawScenarioTestData, )(implicit ec: ExecutionContext, user: LoggedUser): Future[ResultsWithCounts] = { for { preliminaryScenarioTestData <- preliminaryScenarioTestDataSerDe .deserialize(rawTestData) .fold(error => Future.failed(new IllegalArgumentException(error)), Future.successful) - canonical = toCanonicalProcess(scenarioGraph, idWithName.name, isFragment) + canonical = toCanonicalProcess(scenarioGraph, idWithName.name, isFragment, labels) scenarioTestData <- testInfoProvider .prepareTestData(preliminaryScenarioTestData, canonical) .fold(error => Future.failed(new IllegalArgumentException(error)), Future.successful) @@ -102,9 +111,10 @@ class ScenarioTestService( idWithName: ProcessIdWithName, scenarioGraph: ScenarioGraph, isFragment: Boolean, + labels: List[ScenarioLabel], parameterTestData: TestSourceParameters, )(implicit ec: ExecutionContext, user: LoggedUser): Future[ResultsWithCounts] = { - val canonical = toCanonicalProcess(scenarioGraph, idWithName.name, isFragment) + val canonical = toCanonicalProcess(scenarioGraph, idWithName.name, isFragment, labels) for { testResults <- testExecutorService.testProcess( idWithName, @@ -129,9 +139,10 @@ class ScenarioTestService( private def toCanonicalProcess( scenarioGraph: ScenarioGraph, processName: ProcessName, - isFragment: Boolean + isFragment: Boolean, + labels: List[ScenarioLabel] )(implicit user: LoggedUser): CanonicalProcess = { - processResolver.validateAndResolve(scenarioGraph, processName, isFragment) + processResolver.validateAndResolve(scenarioGraph, processName, isFragment, labels) } private def assertTestResultsAreNotTooBig(testResults: TestResults[_]): Future[Unit] = { diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala index cf335682763..602c90db1e9 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala @@ -53,6 +53,7 @@ import pl.touk.nussknacker.ui.process.deployment.{ ScenarioTestExecutorServiceImpl } import pl.touk.nussknacker.ui.process.fragment.{DefaultFragmentRepository, FragmentResolver} +import pl.touk.nussknacker.ui.process.label.ScenarioLabelsService import pl.touk.nussknacker.ui.process.migrate.{HttpRemoteEnvironment, ProcessModelMigrator, TestModelMigrations} import pl.touk.nussknacker.ui.process.newactivity.ActivityService import pl.touk.nussknacker.ui.process.newdeployment.synchronize.{ @@ -79,6 +80,13 @@ import pl.touk.nussknacker.ui.statistics.{ } import pl.touk.nussknacker.ui.suggester.ExpressionSuggester import pl.touk.nussknacker.ui.uiresolving.UIProcessResolver +import pl.touk.nussknacker.ui.util.{CorsSupport, OptionsMethodSupport, SecurityHeadersSupport, WithDirectives} +import pl.touk.nussknacker.ui.validation.{ + NodeValidator, + ParametersValidator, + ScenarioLabelsValidator, + UIProcessValidator +} import pl.touk.nussknacker.ui.util._ import pl.touk.nussknacker.ui.validation.{NodeValidator, ParametersValidator, UIProcessValidator} import sttp.client3.SttpBackend @@ -152,11 +160,14 @@ class AkkaHttpBasedRouteProvider( implicit val implicitDbioRunner: DBIOActionRunner = dbioRunner val commentRepository = new CommentRepository(dbRef) - val actionRepository = new DbProcessActionRepository(dbRef, commentRepository, modelBuildInfo) - val processRepository = DBFetchingProcessRepository.create(dbRef, actionRepository) + val actionRepository = new DbProcessActionRepository(dbRef, commentRepository, modelBuildInfo) + val scenarioLabelsRepository = new ScenarioLabelsRepository(dbRef) + val processRepository = DBFetchingProcessRepository.create(dbRef, actionRepository, scenarioLabelsRepository) // TODO: get rid of Future based repositories - it is easier to use everywhere one implementation - DBIOAction based which allows transactions handling - val futureProcessRepository = DBFetchingProcessRepository.createFutureRepository(dbRef, actionRepository) - val writeProcessRepository = ProcessRepository.create(dbRef, commentRepository, migrations) + val futureProcessRepository = + DBFetchingProcessRepository.createFutureRepository(dbRef, actionRepository, scenarioLabelsRepository) + val writeProcessRepository = + ProcessRepository.create(dbRef, commentRepository, scenarioLabelsRepository, migrations) val fragmentRepository = new DefaultFragmentRepository(futureProcessRepository) val fragmentResolver = new FragmentResolver(fragmentRepository) @@ -167,6 +178,7 @@ class AkkaHttpBasedRouteProvider( ProcessValidator.default(processingTypeData.designerModelData.modelData), processingTypeData.deploymentData.scenarioPropertiesConfig, new ScenarioPropertiesConfigFinalizer(additionalUIConfigProvider, processingTypeData.name), + new ScenarioLabelsValidator(featureTogglesConfig.scenarioLabelConfig), processingTypeData.deploymentData.additionalValidators, fragmentResolver ) @@ -238,7 +250,14 @@ class AkkaHttpBasedRouteProvider( val authenticationResources = AuthenticationResources(resolvedConfig, getClass.getClassLoader, sttpBackend) val authManager = new AuthManager(authenticationResources) - Initialization.init(migrations, dbRef, processRepository, commentRepository, environment) + Initialization.init( + migrations, + dbRef, + processRepository, + commentRepository, + scenarioLabelsRepository, + environment + ) val newProcessPreparer = processingTypeDataProvider.mapValues { processingTypeData => new NewProcessPreparer( @@ -323,6 +342,15 @@ class AkkaHttpBasedRouteProvider( categories = processingTypeDataProvider.mapValues(_.category) ) + val scenarioLabelsApiHttpService = new ScenarioLabelsApiHttpService( + authManager = authManager, + service = new ScenarioLabelsService( + scenarioLabelsRepository, + new ScenarioLabelsValidator(featureTogglesConfig.scenarioLabelConfig), + dbioRunner + ) + ) + val managementApiHttpService = new ManagementApiHttpService( authManager = authManager, dispatcher = dmDispatcher, @@ -530,6 +558,7 @@ class AkkaHttpBasedRouteProvider( nodesApiHttpService, notificationApiHttpService, scenarioActivityApiHttpService, + scenarioLabelsApiHttpService, scenarioParametersHttpService, userApiHttpService, statisticsApiHttpService diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/uiresolving/UIProcessResolver.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/uiresolving/UIProcessResolver.scala index a952770a3c2..beec8d38b97 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/uiresolving/UIProcessResolver.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/uiresolving/UIProcessResolver.scala @@ -7,6 +7,7 @@ import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess import pl.touk.nussknacker.engine.dict.ProcessDictSubstitutor import pl.touk.nussknacker.restmodel.validation.ScenarioGraphWithValidationResult import pl.touk.nussknacker.restmodel.validation.ValidationResults.ValidationResult +import pl.touk.nussknacker.ui.process.label.ScenarioLabel import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter import pl.touk.nussknacker.ui.security.api.LoggedUser import pl.touk.nussknacker.ui.validation.UIProcessValidator @@ -20,17 +21,27 @@ class UIProcessResolver(uiValidator: UIProcessValidator, substitutor: ProcessDic private val beforeUiResolvingValidator = uiValidator.transformValidator(_.withLabelsDictTyper) - def validateAndResolve(scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean)( + def validateAndResolve( + scenarioGraph: ScenarioGraph, + processName: ProcessName, + isFragment: Boolean, + labels: List[ScenarioLabel] + )( implicit loggedUser: LoggedUser ): CanonicalProcess = { - val validationResult = validateBeforeUiResolving(scenarioGraph, processName, isFragment) + val validationResult = validateBeforeUiResolving(scenarioGraph, processName, isFragment, labels) resolveExpressions(scenarioGraph, processName, validationResult.typingInfo) } - def validateBeforeUiResolving(scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean)( + def validateBeforeUiResolving( + scenarioGraph: ScenarioGraph, + processName: ProcessName, + isFragment: Boolean, + labels: List[ScenarioLabel] + )( implicit loggedUser: LoggedUser ): ValidationResult = { - beforeUiResolvingValidator.validate(scenarioGraph, processName, isFragment) + beforeUiResolvingValidator.validate(scenarioGraph, processName, isFragment, labels) } def resolveExpressions( @@ -46,9 +57,10 @@ class UIProcessResolver(uiValidator: UIProcessValidator, substitutor: ProcessDic canonical: CanonicalProcess, processName: ProcessName, isFragment: Boolean, + labels: List[ScenarioLabel] )(implicit loggedUser: LoggedUser): ScenarioGraphWithValidationResult = { val validationResult = validateBeforeUiReverseResolving(canonical, isFragment) - reverseResolveExpressions(canonical, processName, isFragment, validationResult) + reverseResolveExpressions(canonical, processName, isFragment, labels, validationResult) } def validateBeforeUiReverseResolving(canonical: CanonicalProcess, isFragment: Boolean)( @@ -60,11 +72,12 @@ class UIProcessResolver(uiValidator: UIProcessValidator, substitutor: ProcessDic canonical: CanonicalProcess, processName: ProcessName, isFragment: Boolean, + labels: List[ScenarioLabel], validationResult: ValidationResult ): ScenarioGraphWithValidationResult = { val substituted = substitutor.reversed.substitute(canonical, validationResult.typingInfo) val scenarioGraph = CanonicalProcessConverter.toScenarioGraph(substituted) - val uiValidations = uiValidator.uiValidation(scenarioGraph, processName, isFragment) + val uiValidations = uiValidator.uiValidation(scenarioGraph, processName, isFragment, labels) ScenarioGraphWithValidationResult(scenarioGraph, uiValidations.add(validationResult)) } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/validation/ScenarioLabelsValidator.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/validation/ScenarioLabelsValidator.scala new file mode 100644 index 00000000000..1d1b6346db9 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/validation/ScenarioLabelsValidator.scala @@ -0,0 +1,79 @@ +package pl.touk.nussknacker.ui.validation + +import cats.data.{NonEmptyList, Validated, ValidatedNel} +import cats.implicits._ +import pl.touk.nussknacker.ui.config.ScenarioLabelConfig +import pl.touk.nussknacker.ui.process.label.ScenarioLabel +import pl.touk.nussknacker.ui.validation.ScenarioLabelsValidator.ValidationError + +class ScenarioLabelsValidator(config: Option[ScenarioLabelConfig]) { + + def validate(labels: List[ScenarioLabel]): ValidatedNel[ValidationError, Unit] = { + (for { + validationRules <- getValidationRules + scenarioLabels <- NonEmptyList.fromList(labels) + result = validateLabels(scenarioLabels, validationRules) + } yield result).getOrElse(Validated.validNel(())) + } + + private def validateLabels( + scenarioLabels: NonEmptyList[ScenarioLabel], + validationRules: NonEmptyList[ScenarioLabelConfig.ValidationRule] + ): Validated[NonEmptyList[ValidationError], Unit] = { + scenarioLabels + .traverse(label => validateScenarioLabel(validationRules, label).toValidatedNel) + .andThen(labels => validateUniqueness(labels)) + } + + private def getValidationRules = + config + .flatMap { settings => + NonEmptyList.fromList(settings.validationRules) + } + + private def validateScenarioLabel( + validationRules: NonEmptyList[ScenarioLabelConfig.ValidationRule], + label: ScenarioLabel + ): Validated[ValidationError, ScenarioLabel] = { + validationRules + .traverse(rule => validate(label, rule)) + .as(label) + .leftMap(messages => ValidationError(label.value, messages)) + } + + private def validate(label: ScenarioLabel, rule: ScenarioLabelConfig.ValidationRule) = { + Validated + .cond( + // in scala 2.13 we can use `matches`, but there is no such method in scala 2.12 + rule.validationRegex.unapplySeq(label.value).isDefined, + (), + rule.messageWithLabel(label.value) + ) + .toValidatedNel + } + + private def validateUniqueness(labels: NonEmptyList[ScenarioLabel]) = { + labels.toList + .groupBy(identity) + .filter { case (_, values) => + values.length > 1 + } + .keys + .toList + .toNel match { + case Some(notUniqueLabels) => + Validated.invalid( + notUniqueLabels + .map(label => ValidationError(label.value, NonEmptyList.one("Label has to be unique"))) + ) + case None => + Validated.validNel(()) + } + } + +} + +object ScenarioLabelsValidator { + + final case class ValidationError(label: String, validationMessages: NonEmptyList[String]) +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/validation/UIProcessValidator.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/validation/UIProcessValidator.scala index c27732d9fd2..16f8ba6ef53 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/validation/UIProcessValidator.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/validation/UIProcessValidator.scala @@ -1,6 +1,6 @@ package pl.touk.nussknacker.ui.validation -import cats.data.NonEmptyList +import cats.data.{NonEmptyList, Validated} import cats.data.Validated.{Invalid, Valid} import pl.touk.nussknacker.engine.api.component.ScenarioPropertyConfig import pl.touk.nussknacker.engine.api.context.ProcessCompilationError @@ -21,6 +21,7 @@ import pl.touk.nussknacker.restmodel.validation.ValidationResults.{ } import pl.touk.nussknacker.ui.definition.{DefinitionsService, ScenarioPropertiesConfigFinalizer} import pl.touk.nussknacker.ui.process.fragment.FragmentResolver +import pl.touk.nussknacker.ui.process.label.ScenarioLabel import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter import pl.touk.nussknacker.ui.security.api.LoggedUser @@ -29,6 +30,7 @@ class UIProcessValidator( validator: ProcessValidator, scenarioProperties: Map[String, ScenarioPropertyConfig], scenarioPropertiesConfigFinalizer: ScenarioPropertiesConfigFinalizer, + scenarioLabelsValidator: ScenarioLabelsValidator, additionalValidators: List[CustomProcessValidator], fragmentResolver: FragmentResolver, ) { @@ -44,6 +46,7 @@ class UIProcessValidator( validator, scenarioProperties, scenarioPropertiesConfigFinalizer, + scenarioLabelsValidator, additionalValidators, fragmentResolver ) @@ -54,25 +57,20 @@ class UIProcessValidator( transform(validator), scenarioProperties, scenarioPropertiesConfigFinalizer, + scenarioLabelsValidator, additionalValidators, fragmentResolver ) - // TODO: It is used only in tests, remove it from the prodcution code - def withScenarioPropertiesConfig(scenarioPropertiesConfig: Map[String, ScenarioPropertyConfig]) = - new UIProcessValidator( - processingType, - validator, - scenarioPropertiesConfig, - scenarioPropertiesConfigFinalizer, - additionalValidators, - fragmentResolver - ) - - def validate(scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean)( + def validate( + scenarioGraph: ScenarioGraph, + processName: ProcessName, + isFragment: Boolean, + labels: List[ScenarioLabel] + )( implicit loggedUser: LoggedUser ): ValidationResult = { - val uiValidationResult = uiValidation(scenarioGraph, processName, isFragment) + val uiValidationResult = uiValidation(scenarioGraph, processName, isFragment, labels) // TODO: Enable further validation when save is not allowed // The problem preventing further validation is that loose nodes and their children are skipped during conversion @@ -91,8 +89,14 @@ class UIProcessValidator( // is an error preventing graph canonization. For example we want to display node and scenario id errors for scenarios // that have loose nodes. If you want to achieve this result, you need to add these validations here and deduplicate // resulting errors later. - def uiValidation(scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean): ValidationResult = { + def uiValidation( + scenarioGraph: ScenarioGraph, + processName: ProcessName, + isFragment: Boolean, + labels: List[ScenarioLabel] + ): ValidationResult = { validateScenarioName(processName, isFragment) + .add(validateScenarioLabels(labels)) .add(validateNodesId(scenarioGraph)) .add(validateDuplicates(scenarioGraph)) .add(validateLooseNodes(scenarioGraph)) @@ -183,6 +187,23 @@ class UIProcessValidator( } } + private def validateScenarioLabels(labels: List[ScenarioLabel]): ValidationResult = { + scenarioLabelsValidator.validate(labels) match { + case Valid(()) => + ValidationResult.success + case Invalid(errors) => + ValidationResult.globalErrors( + errors + .map(ve => + ScenarioLabelValidationError(label = ve.label, description = ve.validationMessages.toList.mkString(", ")) + ) + .map(PrettyValidationErrors.formatErrorMessage) + .map(UIGlobalError(_, nodeIds = List.empty)) + .toList + ) + } + } + private def validateScenarioProperties( properties: Map[String, String], isFragment: Boolean diff --git a/designer/server/src/test/resources/config/common-designer.conf b/designer/server/src/test/resources/config/common-designer.conf index 84e0402f55d..508a1300a82 100644 --- a/designer/server/src/test/resources/config/common-designer.conf +++ b/designer/server/src/test/resources/config/common-designer.conf @@ -54,6 +54,19 @@ testDataSettings: { resultsMaxBytes: 50000000 } +scenarioLabelSettings: { + validationRules = [ + { + validationPattern: "^[a-zA-Z0-9-_]+$", + validationMessage: "Scenario label can contain only alphanumeric characters, '-' and '_'" + } + { + validationPattern: "^.{1,10}$" + validationMessage: "Scenario label can contain up to 10 characters" + } + ] +} + notifications { duration: 1 minute } diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/db/DbTesting.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/db/DbTesting.scala index eb4e02e2755..d9f42595808 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/db/DbTesting.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/db/DbTesting.scala @@ -87,7 +87,7 @@ trait DbTesting extends BeforeAndAfterEach with BeforeAndAfterAll { session.prepareStatement("""delete from "process_comments"""").execute() session.prepareStatement("""delete from "process_actions"""").execute() session.prepareStatement("""delete from "process_versions"""").execute() - session.prepareStatement("""delete from "tags"""").execute() + session.prepareStatement("""delete from "scenario_labels"""").execute() session.prepareStatement("""delete from "environments"""").execute() session.prepareStatement("""delete from "processes"""").execute() session.prepareStatement("""delete from "fingerprints"""").execute() diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala index 091deef979d..3cffd84ba90 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala @@ -295,7 +295,7 @@ trait NuResourcesTest protected def updateProcess(process: ScenarioGraph, name: ProcessName = ProcessTestData.sampleProcessName)( testCode: => Assertion ): Assertion = - doUpdateProcess(UpdateScenarioCommand(process, None, None), name)(testCode) + doUpdateProcess(UpdateScenarioCommand(process, None, Some(List.empty), None), name)(testCode) protected def updateCanonicalProcessAndAssertSuccess(process: CanonicalProcess): Assertion = updateCanonicalProcess(process) { @@ -309,6 +309,7 @@ trait NuResourcesTest UpdateScenarioCommand( CanonicalProcessConverter.toScenarioGraph(process), comment.map(UpdateProcessComment(_)), + Some(List.empty), None ), process.name @@ -525,13 +526,19 @@ object ProcessJson extends OptionValues { val state = process.hcursor.downField("state").as[Option[Json]].toOption.value new ProcessJson( - process.hcursor.downField("name").as[String].toOption.value, - lastAction.map(_.hcursor.downField("processVersionId").as[Long].toOption.value), - lastAction.map(_.hcursor.downField("actionName").as[String].toOption.value), - state.map(StateJson(_)), - process.hcursor.downField("processCategory").as[String].toOption.value, - process.hcursor.downField("isArchived").as[Boolean].toOption.value, - process.hcursor.downField("history").as[Option[List[Json]]].toOption.value.map(_.map(v => ProcessVersionJson(v))) + name = process.hcursor.downField("name").as[String].toOption.value, + lastActionVersionId = lastAction.map(_.hcursor.downField("processVersionId").as[Long].toOption.value), + lastActionType = lastAction.map(_.hcursor.downField("actionName").as[String].toOption.value), + state = state.map(StateJson(_)), + processCategory = process.hcursor.downField("processCategory").as[String].toOption.value, + isArchived = process.hcursor.downField("isArchived").as[Boolean].toOption.value, + labels = process.hcursor.downField("labels").as[List[String]].toOption.value, + history = process.hcursor + .downField("history") + .as[Option[List[Json]]] + .toOption + .value + .map(_.map(v => ProcessVersionJson(v))) ) } @@ -544,6 +551,7 @@ final case class ProcessJson( state: Option[StateJson], processCategory: String, isArchived: Boolean, + labels: List[String], // Process on list doesn't contain history history: Option[List[ProcessVersionJson]] ) { diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/OpenAPIExamplesValidator.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/OpenAPIExamplesValidator.scala index bd77d50a291..f0f2ab0fe0a 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/OpenAPIExamplesValidator.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/OpenAPIExamplesValidator.scala @@ -1,6 +1,6 @@ package pl.touk.nussknacker.test.utils -import com.networknt.schema.{InputFormat, JsonSchemaFactory, ValidationMessage} +import com.networknt.schema.{InputFormat, JsonSchemaFactory, SchemaValidatorsConfig, ValidationMessage} import io.circe.yaml.{parser => YamlParser} import io.circe.{ACursor, Json} import org.scalactic.anyvals.NonEmptyList diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ProcessTestData.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ProcessTestData.scala index ea83b21750b..bffd01cb773 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ProcessTestData.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ProcessTestData.scala @@ -1,12 +1,12 @@ package pl.touk.nussknacker.test.utils.domain -import pl.touk.nussknacker.engine.MetaDataInitializer -import pl.touk.nussknacker.engine.api.component.{ComponentGroupName, ProcessingMode} +import pl.touk.nussknacker.engine.{CustomProcessValidator, MetaDataInitializer} +import pl.touk.nussknacker.engine.api.component.{ComponentGroupName, ProcessingMode, ScenarioPropertyConfig} import pl.touk.nussknacker.engine.api.definition._ import pl.touk.nussknacker.engine.api.dict.DictDefinition import pl.touk.nussknacker.engine.api.graph.{Edge, ProcessProperties, ScenarioGraph} import pl.touk.nussknacker.engine.api.parameter.ParameterName -import pl.touk.nussknacker.engine.api.process.ProcessName +import pl.touk.nussknacker.engine.api.process.{ProcessName, ProcessingType} import pl.touk.nussknacker.engine.api.typed.typing.{Typed, Unknown} import pl.touk.nussknacker.engine.api.{FragmentSpecificData, MetaData, ProcessAdditionalFields, StreamMetaData} import pl.touk.nussknacker.engine.build.{GraphBuilder, ScenarioBuilder} @@ -38,7 +38,7 @@ import pl.touk.nussknacker.ui.process.ProcessService.UpdateScenarioCommand import pl.touk.nussknacker.ui.process.fragment.FragmentResolver import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter import pl.touk.nussknacker.ui.process.repository.UpdateProcessComment -import pl.touk.nussknacker.ui.validation.UIProcessValidator +import pl.touk.nussknacker.ui.validation.{ScenarioLabelsValidator, UIProcessValidator} object ProcessTestData { @@ -139,31 +139,51 @@ object ProcessTestData { ) } - def processValidator: UIProcessValidator = new UIProcessValidator( - processingType = Streaming.stringify, - validator = ProcessValidator.default(new StubModelDataWithModelDefinition(modelDefinition())), - scenarioProperties = Map.empty, - scenarioPropertiesConfigFinalizer = - new ScenarioPropertiesConfigFinalizer(TestAdditionalUIConfigProvider, Streaming.stringify), - additionalValidators = List.empty, - fragmentResolver = new FragmentResolver(new StubFragmentRepository(Map.empty)) - ) + private object ProcessValidatorDefaults { + val processingType: ProcessingType = Streaming.stringify + val processValidator: ProcessValidator = + ProcessValidator.default(new StubModelDataWithModelDefinition(modelDefinition())) + val scenarioProperties: Map[String, ScenarioPropertyConfig] = Map.empty + val scenarioPropertiesConfigFinalizer: ScenarioPropertiesConfigFinalizer = + new ScenarioPropertiesConfigFinalizer(TestAdditionalUIConfigProvider, processingType) + val scenarioLabelsValidator: ScenarioLabelsValidator = new ScenarioLabelsValidator(config = None) + val additionalValidators: List[CustomProcessValidator] = List.empty + val fragmentResolver: FragmentResolver = new FragmentResolver(new StubFragmentRepository(Map.empty)) + } - def processValidatorWithDicts(dictionaries: Map[String, DictDefinition]): UIProcessValidator = new UIProcessValidator( - processingType = Streaming.stringify, - validator = ProcessValidator.default(new StubModelDataWithModelDefinition(modelDefinitionWithDicts(dictionaries))), - scenarioProperties = Map.empty, - scenarioPropertiesConfigFinalizer = - new ScenarioPropertiesConfigFinalizer(TestAdditionalUIConfigProvider, Streaming.stringify), - additionalValidators = List.empty, - fragmentResolver = new FragmentResolver(new StubFragmentRepository(Map.empty)) + def testProcessValidator( + processingType: ProcessingType = ProcessValidatorDefaults.processingType, + validator: ProcessValidator = ProcessValidatorDefaults.processValidator, + scenarioProperties: Map[String, ScenarioPropertyConfig] = ProcessValidatorDefaults.scenarioProperties, + scenarioPropertiesConfigFinalizer: ScenarioPropertiesConfigFinalizer = + ProcessValidatorDefaults.scenarioPropertiesConfigFinalizer, + scenarioLabelsValidator: ScenarioLabelsValidator = ProcessValidatorDefaults.scenarioLabelsValidator, + additionalValidators: List[CustomProcessValidator] = ProcessValidatorDefaults.additionalValidators, + fragmentResolver: FragmentResolver = ProcessValidatorDefaults.fragmentResolver + ): UIProcessValidator = new UIProcessValidator( + processingType = processingType, + validator = validator, + scenarioProperties = scenarioProperties, + scenarioPropertiesConfigFinalizer = scenarioPropertiesConfigFinalizer, + scenarioLabelsValidator = scenarioLabelsValidator, + additionalValidators = additionalValidators, + fragmentResolver = fragmentResolver ) + def processValidator: UIProcessValidator = testProcessValidator() + + def processValidatorWithDicts(dictionaries: Map[String, DictDefinition]): UIProcessValidator = + testProcessValidator( + validator = ProcessValidator.default(new StubModelDataWithModelDefinition(modelDefinitionWithDicts(dictionaries))) + ) + val sampleScenarioParameters: ScenarioParameters = ScenarioParameters(ProcessingMode.UnboundedStream, "Category1", EngineSetupName("Stub Engine")) val sampleProcessName: ProcessName = ProcessName("fooProcess") + val sampleScenarioLabels: List[String] = List("tag1", "tag2") + val validProcess: CanonicalProcess = validProcessWithName(sampleProcessName) val validProcessWithEmptySpelExpr: CanonicalProcess = @@ -406,7 +426,7 @@ object ProcessTestData { edges = List.empty ) - UpdateScenarioCommand(scenarioGraph, comment, None) + UpdateScenarioCommand(scenarioGraph, comment, Some(List.empty), None) } def validProcessWithFragment( diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala index 7ae550d4e4f..3e2efd504e4 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala @@ -36,14 +36,17 @@ private[test] class ScenarioHelper(dbRef: DbRef, designerConfig: Config)(implici mapProcessingTypeDataProvider(Map("engine-version" -> "0.1")) ) with DbioRepository + private val scenarioLabelsRepository: ScenarioLabelsRepository = new ScenarioLabelsRepository(dbRef) + private val writeScenarioRepository: DBProcessRepository = new DBProcessRepository( dbRef, new CommentRepository(dbRef), + scenarioLabelsRepository, mapProcessingTypeDataProvider(1) ) private val futureFetchingScenarioRepository: DBFetchingProcessRepository[Future] = - new DBFetchingProcessRepository[Future](dbRef, actionRepository) with BasicRepository + new DBFetchingProcessRepository[Future](dbRef, actionRepository, scenarioLabelsRepository) with BasicRepository def createEmptyScenario(scenarioName: ProcessName, category: String, isFragment: Boolean): ProcessId = { val newProcessPreparer: NewProcessPreparer = new NewProcessPreparer( diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioToJsonHelper.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioToJsonHelper.scala index 89802f1c077..7700ac136f8 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioToJsonHelper.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioToJsonHelper.scala @@ -14,8 +14,15 @@ object ScenarioToJsonHelper { private implicit val ptsEncoder: Encoder[UpdateScenarioCommand] = deriveConfiguredEncoder implicit class ScenarioGraphToJson(scenarioGraph: ScenarioGraph) { + def toJsonAsProcessToSave: Json = - UpdateScenarioCommand(scenarioGraph, comment = None, forwardedUserName = None).asJson + UpdateScenarioCommand( + scenarioGraph, + comment = None, + scenarioLabels = Some(List.empty), + forwardedUserName = None + ).asJson + } implicit class ScenarioToJson(scenario: CanonicalProcess) { diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/TestFactory.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/TestFactory.scala index 2ad2970ea7e..53dab64cc12 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/TestFactory.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/TestFactory.scala @@ -66,11 +66,12 @@ object TestFactory { val possibleValues: List[FixedExpressionValue] = List(FixedExpressionValue("a", "a")) - val processValidator: UIProcessValidator = ProcessTestData.processValidator.withFragmentResolver(sampleResolver) + val processValidator: UIProcessValidator = ProcessTestData.testProcessValidator(fragmentResolver = sampleResolver) - val flinkProcessValidator: UIProcessValidator = ProcessTestData.processValidator - .withFragmentResolver(sampleResolver) - .withScenarioPropertiesConfig(FlinkStreamingPropertiesConfig.properties) + val flinkProcessValidator: UIProcessValidator = ProcessTestData.testProcessValidator( + fragmentResolver = sampleResolver, + scenarioProperties = FlinkStreamingPropertiesConfig.properties + ) val processValidatorByProcessingType: ProcessingTypeDataProvider[UIProcessValidator, _] = mapProcessingTypeDataProvider(Streaming.stringify -> flinkProcessValidator) @@ -145,16 +146,24 @@ object TestFactory { def newCommentRepository(dbRef: DbRef) = new CommentRepository(dbRef) + def newScenarioLabelsRepository(dbRef: DbRef) = new ScenarioLabelsRepository(dbRef) + def newFutureFetchingScenarioRepository(dbRef: DbRef) = - new DBFetchingProcessRepository[Future](dbRef, newActionProcessRepository(dbRef)) with BasicRepository + new DBFetchingProcessRepository[Future]( + dbRef, + newActionProcessRepository(dbRef), + newScenarioLabelsRepository(dbRef) + ) with BasicRepository def newFetchingProcessRepository(dbRef: DbRef) = - new DBFetchingProcessRepository[DB](dbRef, newActionProcessRepository(dbRef)) with DbioRepository + new DBFetchingProcessRepository[DB](dbRef, newActionProcessRepository(dbRef), newScenarioLabelsRepository(dbRef)) + with DbioRepository def newWriteProcessRepository(dbRef: DbRef, modelVersions: Option[Int] = Some(1)) = new DBProcessRepository( dbRef, newCommentRepository(dbRef), + newScenarioLabelsRepository(dbRef), mapProcessingTypeDataProvider(modelVersions.map(Streaming.stringify -> _).toList: _*) ) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/TestProcessUtil.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/TestProcessUtil.scala index e1c95cf9221..f43510933f5 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/TestProcessUtil.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/TestProcessUtil.scala @@ -100,6 +100,7 @@ object TestProcessUtil { processingType: ProcessingType = ProcessingTypeStreaming, lastAction: Option[ScenarioActionName] = None, description: Option[String] = None, + scenarioLabels: List[String] = List.empty, history: Option[List[ScenarioVersion]] = None ): ScenarioWithDetailsEntity[ScenarioGraph] = { val jsonData = scenarioGraph @@ -119,7 +120,7 @@ object TestProcessUtil { modifiedBy = "user1", createdAt = Instant.now(), createdBy = "user1", - tags = None, + scenarioLabels = scenarioLabels, lastAction = lastAction.map(createProcessAction), lastStateAction = lastAction.collect { case action if ScenarioActionName.StateActions.contains(action) => createProcessAction(action) @@ -137,6 +138,7 @@ object TestProcessUtil { scenarioGraph: ScenarioGraph, name: ProcessName = ProcessTestData.sampleProcessName, isFragment: Boolean = false, + labels: List[String] = List.empty, validationResult: ValidationResult = ValidationResult.success ): ScenarioWithDetailsForMigrations = { ScenarioWithDetailsForMigrations( @@ -145,6 +147,7 @@ object TestProcessUtil { isFragment = isFragment, processingType = ProcessingTypeStreaming, processCategory = "Category1", + labels = labels, scenarioGraph = Some(scenarioGraph), validationResult = Some(validationResult), history = None, diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/MigrationApiHttpServiceBusinessSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/MigrationApiHttpServiceBusinessSpec.scala index 2de7f418b46..08c84622551 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/MigrationApiHttpServiceBusinessSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/MigrationApiHttpServiceBusinessSpec.scala @@ -20,9 +20,7 @@ import pl.touk.nussknacker.test.{ RestAssuredVerboseLoggingIfValidationFails, StandardPatientScalaFutures } -import pl.touk.nussknacker.ui.migrations.{MigrateScenarioData, MigrationApiAdapters} import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter -import pl.touk.nussknacker.ui.util.ApiAdapter // FIXME: For migrating between different API version should be written end to end test (e2e-tests directory) class MigrationApiHttpServiceBusinessSpec @@ -37,8 +35,6 @@ class MigrationApiHttpServiceBusinessSpec with Eventually with StandardPatientScalaFutures { - val adapters: Map[Int, ApiAdapter[MigrateScenarioData]] = MigrationApiAdapters.adapters - "The endpoint for migration api version should" - { "return current api version" in { given() @@ -49,64 +45,122 @@ class MigrationApiHttpServiceBusinessSpec .statusCode(200) .body( "version", - equalTo[Int](1) + equalTo[Int](2) ) } } "The endpoint for scenario migration between environments should" - { - "migrate scenario and add update comment when scenario does not exist on target environment" in { - given() - .when() - .basicAuthAllPermUser() - .jsonBody(validRequestData) - .post(s"$nuDesignerHttpAddress/api/migrate") - .Then() - .statusCode(200) - .verifyApplicationState { - eventually { - verifyCommentExists(exampleProcessName.value, "Scenario migrated from DEV by remoteUser", "allpermuser") - verifyScenarioAfterMigration( - exampleProcessName.value, - processVersionId = 2, - isFragment = false, - modifiedBy = "remoteUser", - createdBy = "remoteUser", - modelVersion = 0, - historyProcessVersions = List(1, 2), - scenarioGraphNodeIds = List("sink", "source") - ) + "migrate scenario and add update comment when scenario does not exist on target environment when" - { + "migration from current data version" in { + given() + .when() + .basicAuthAllPermUser() + .jsonBody(validRequestData) + .post(s"$nuDesignerHttpAddress/api/migrate") + .Then() + .statusCode(200) + .verifyApplicationState { + eventually { + verifyCommentExists(exampleProcessName.value, "Scenario migrated from DEV by remoteUser", "allpermuser") + verifyScenarioAfterMigration( + exampleProcessName.value, + processVersionId = 2, + isFragment = false, + scenarioLabels = exampleScenarioLabels, + modifiedBy = "remoteUser", + createdBy = "remoteUser", + modelVersion = 0, + historyProcessVersions = List(1, 2), + scenarioGraphNodeIds = List("sink", "source") + ) + } + } + } + "migration from data version 1" in { + given() + .when() + .basicAuthAllPermUser() + .jsonBody(prepareRequestJsonDataV1(exampleProcessName.value, exampleGraph, isFragment = false)) + .post(s"$nuDesignerHttpAddress/api/migrate") + .Then() + .statusCode(200) + .verifyApplicationState { + eventually { + verifyCommentExists(exampleProcessName.value, "Scenario migrated from DEV by remoteUser", "allpermuser") + verifyScenarioAfterMigration( + exampleProcessName.value, + processVersionId = 2, + isFragment = false, + scenarioLabels = List.empty, + modifiedBy = "remoteUser", + createdBy = "remoteUser", + modelVersion = 0, + historyProcessVersions = List(1, 2), + scenarioGraphNodeIds = List("sink", "source") + ) + } } - } + } } - "migrate scenario and add update comment when scenario exists on target environment" in { - given() - .applicationState( - createSavedScenario(exampleScenario) - ) - .when() - .basicAuthAllPermUser() - .jsonBody(validRequestDataV2) - .post(s"$nuDesignerHttpAddress/api/migrate") - .Then() - .statusCode(200) - .verifyApplicationState { - eventually { - verifyCommentExists(exampleProcessName.value, "Scenario migrated from DEV by remoteUser", "allpermuser") - verifyScenarioAfterMigration( - scenarioName = exampleProcessName.value, - processVersionId = 2, - isFragment = false, - modifiedBy = "remoteUser", - createdBy = "admin", - modelVersion = 0, - historyProcessVersions = List(1, 2), - scenarioGraphNodeIds = List("sink2", "source2") - ) + "migrate scenario and add update comment when scenario exists on target environment when" - { + "migration from current data version" in { + given() + .applicationState( + createSavedScenario(exampleScenario) + ) + .when() + .basicAuthAllPermUser() + .jsonBody(validRequestDataV2) + .post(s"$nuDesignerHttpAddress/api/migrate") + .Then() + .statusCode(200) + .verifyApplicationState { + eventually { + verifyCommentExists(exampleProcessName.value, "Scenario migrated from DEV by remoteUser", "allpermuser") + verifyScenarioAfterMigration( + scenarioName = exampleProcessName.value, + processVersionId = 2, + isFragment = false, + scenarioLabels = exampleScenarioLabels, + modifiedBy = "remoteUser", + createdBy = "admin", + modelVersion = 0, + historyProcessVersions = List(1, 2), + scenarioGraphNodeIds = List("sink2", "source2") + ) + } + } + } + "migration from data version 1" in { + given() + .applicationState( + createSavedScenario(exampleScenario) + ) + .when() + .basicAuthAllPermUser() + .jsonBody(prepareRequestJsonDataV1(exampleProcessName.value, exampleGraphV2, isFragment = false)) + .post(s"$nuDesignerHttpAddress/api/migrate") + .Then() + .statusCode(200) + .verifyApplicationState { + eventually { + verifyCommentExists(exampleProcessName.value, "Scenario migrated from DEV by remoteUser", "allpermuser") + verifyScenarioAfterMigration( + scenarioName = exampleProcessName.value, + processVersionId = 2, + isFragment = false, + scenarioLabels = List.empty, + modifiedBy = "remoteUser", + createdBy = "admin", + modelVersion = 0, + historyProcessVersions = List(1, 2), + scenarioGraphNodeIds = List("sink2", "source2") + ) + } } - } + } } - "fail when scenario name contains illegal character(s)" in { given() .when() @@ -134,63 +188,124 @@ class MigrationApiHttpServiceBusinessSpec s"Cannot migrate, scenario ${exampleProcessName.value} is archived on test. You have to unarchive scenario on test in order to migrate." ) } - "migrate fragment and add update comment when fragment exists in target environment" in { - given() - .applicationState( - createSavedFragment(validFragment) - ) - .when() - .basicAuthAllPermUser() - .jsonBody(validRequestDataForFragmentV2) - .post(s"$nuDesignerHttpAddress/api/migrate") - .Then() - .statusCode(200) - .verifyApplicationState { - eventually { - verifyCommentExists(validFragment.name.value, "Scenario migrated from DEV by remoteUser", "allpermuser") - verifyScenarioAfterMigration( - validFragment.name.value, - processVersionId = 2, - isFragment = true, - modifiedBy = "remoteUser", - createdBy = "admin", - modelVersion = 0, - historyProcessVersions = List(1, 2), - scenarioGraphNodeIds = List("sink2", "csv-source-lite") - ) + "migrate fragment and add update comment when fragment exists in target environment when" - { + "migration from current data version" in { + given() + .applicationState( + createSavedFragment(validFragment) + ) + .when() + .basicAuthAllPermUser() + .jsonBody(validRequestDataForFragmentV2) + .post(s"$nuDesignerHttpAddress/api/migrate") + .Then() + .statusCode(200) + .verifyApplicationState { + eventually { + verifyCommentExists(validFragment.name.value, "Scenario migrated from DEV by remoteUser", "allpermuser") + verifyScenarioAfterMigration( + validFragment.name.value, + processVersionId = 2, + isFragment = true, + scenarioLabels = exampleScenarioLabels, + modifiedBy = "remoteUser", + createdBy = "admin", + modelVersion = 0, + historyProcessVersions = List(1, 2), + scenarioGraphNodeIds = List("sink2", "csv-source-lite") + ) + } } - } + } + "migration from data version 1" in { + given() + .applicationState( + createSavedFragment(validFragment) + ) + .when() + .basicAuthAllPermUser() + .jsonBody(prepareRequestJsonDataV1(validFragment.name.value, exampleFragmentGraphV2, isFragment = true)) + .post(s"$nuDesignerHttpAddress/api/migrate") + .Then() + .statusCode(200) + .verifyApplicationState { + eventually { + verifyCommentExists(validFragment.name.value, "Scenario migrated from DEV by remoteUser", "allpermuser") + verifyScenarioAfterMigration( + validFragment.name.value, + processVersionId = 2, + isFragment = true, + scenarioLabels = List.empty, + modifiedBy = "remoteUser", + createdBy = "admin", + modelVersion = 0, + historyProcessVersions = List(1, 2), + scenarioGraphNodeIds = List("sink2", "csv-source-lite") + ) + } + } + } + } - "migrate fragment and add update comment when fragment does not exist in target environment" in { - given() - .when() - .basicAuthAllPermUser() - .jsonBody(validRequestDataForFragment) - .post(s"$nuDesignerHttpAddress/api/migrate") - .Then() - .statusCode(200) - .verifyApplicationState { - eventually { - verifyCommentExists(validFragment.name.value, "Scenario migrated from DEV by remoteUser", "allpermuser") - verifyScenarioAfterMigration( - validFragment.name.value, - processVersionId = 2, - isFragment = true, - modifiedBy = "remoteUser", - createdBy = "remoteUser", - modelVersion = 0, - historyProcessVersions = List(1, 2), - scenarioGraphNodeIds = List("sink", "csv-source-lite") - ) + "migrate fragment and add update comment when fragment does not exist in target environment when" - { + "migration from current data version" in { + given() + .when() + .basicAuthAllPermUser() + .jsonBody(validRequestDataForFragment) + .post(s"$nuDesignerHttpAddress/api/migrate") + .Then() + .statusCode(200) + .verifyApplicationState { + eventually { + verifyCommentExists(validFragment.name.value, "Scenario migrated from DEV by remoteUser", "allpermuser") + verifyScenarioAfterMigration( + validFragment.name.value, + processVersionId = 2, + isFragment = true, + scenarioLabels = exampleScenarioLabels, + modifiedBy = "remoteUser", + createdBy = "remoteUser", + modelVersion = 0, + historyProcessVersions = List(1, 2), + scenarioGraphNodeIds = List("sink", "csv-source-lite") + ) + } + } + } + "migration from data version 1" in { + given() + .when() + .basicAuthAllPermUser() + .jsonBody(prepareRequestJsonDataV1(validFragment.name.value, exampleFragmentGraph, isFragment = true)) + .post(s"$nuDesignerHttpAddress/api/migrate") + .Then() + .statusCode(200) + .verifyApplicationState { + eventually { + verifyCommentExists(validFragment.name.value, "Scenario migrated from DEV by remoteUser", "allpermuser") + verifyScenarioAfterMigration( + validFragment.name.value, + processVersionId = 2, + isFragment = true, + scenarioLabels = List.empty, + modifiedBy = "remoteUser", + createdBy = "remoteUser", + modelVersion = 0, + historyProcessVersions = List(1, 2), + scenarioGraphNodeIds = List("sink", "csv-source-lite") + ) + } } - } + } } } private lazy val sourceEnvironmentId = "DEV" - private lazy val exampleProcessName = ProcessName("test2") - private lazy val illegalProcessName = ProcessName("#test") + private lazy val exampleProcessName = ProcessName("test2") + private lazy val exampleScenarioLabels = List("tag1", "tag2") + private lazy val illegalProcessName = ProcessName("#test") private lazy val exampleScenario = ScenarioBuilder @@ -218,7 +333,7 @@ class MigrationApiHttpServiceBusinessSpec private lazy val exampleFragmentGraphV2 = CanonicalProcessConverter.toScenarioGraph(validFragmentV2) - private def prepareRequestJsonData( + private def prepareRequestJsonDataV1( scenarioName: String, scenarioGraph: ScenarioGraph, isFragment: Boolean @@ -237,25 +352,72 @@ class MigrationApiHttpServiceBusinessSpec |} |""".stripMargin + private def prepareRequestJsonDataV2( + scenarioName: String, + scenarioGraph: ScenarioGraph, + isFragment: Boolean, + scenarioLabels: List[String] + ): String = + s""" + |{ + | "version": "2", + | "sourceEnvironmentId": "$sourceEnvironmentId", + | "remoteUserName": "remoteUser", + | "processingMode": "Unbounded-Stream", + | "engineSetupName": "Mockable", + | "processName": "$scenarioName", + | "isFragment": $isFragment, + | "processCategory": "${Category1.stringify}", + | "scenarioLabels": ${scenarioLabels.asJson.noSpaces}, + | "scenarioGraph": ${scenarioGraph.asJson.noSpaces} + |} + |""".stripMargin + private lazy val validRequestData: String = - prepareRequestJsonData(exampleProcessName.value, exampleGraph, isFragment = false) + prepareRequestJsonDataV2( + exampleProcessName.value, + exampleGraph, + isFragment = false, + scenarioLabels = List("tag1", "tag2") + ) private lazy val validRequestDataV2: String = - prepareRequestJsonData(exampleProcessName.value, exampleGraphV2, isFragment = false) + prepareRequestJsonDataV2( + exampleProcessName.value, + exampleGraphV2, + isFragment = false, + scenarioLabels = exampleScenarioLabels + ) private lazy val requestDataWithInvalidScenarioName: String = - prepareRequestJsonData(illegalProcessName.value, exampleGraph, isFragment = false) + prepareRequestJsonDataV2( + illegalProcessName.value, + exampleGraph, + isFragment = false, + scenarioLabels = exampleScenarioLabels + ) private lazy val validRequestDataForFragment: String = - prepareRequestJsonData(validFragment.name.value, exampleFragmentGraph, isFragment = true) + prepareRequestJsonDataV2( + validFragment.name.value, + exampleFragmentGraph, + isFragment = true, + scenarioLabels = exampleScenarioLabels + ) private lazy val validRequestDataForFragmentV2: String = - prepareRequestJsonData(validFragment.name.value, exampleFragmentGraphV2, isFragment = true) + prepareRequestJsonDataV2( + validFragment.name.value, + exampleFragmentGraphV2, + isFragment = true, + scenarioLabels = exampleScenarioLabels + ) private def verifyScenarioAfterMigration( scenarioName: String, processVersionId: Int, isFragment: Boolean, + scenarioLabels: List[String], modifiedBy: String, createdBy: String, modelVersion: Int, @@ -276,6 +438,8 @@ class MigrationApiHttpServiceBusinessSpec equalTo(processVersionId), "isFragment", equalTo(isFragment), + "labels", + containsInAnyOrder(scenarioLabels: _*), "modifiedBy", equalTo(modifiedBy), "createdBy", diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/MigrationApiHttpServiceSecuritySpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/MigrationApiHttpServiceSecuritySpec.scala index 454f4c1488e..856c006d782 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/MigrationApiHttpServiceSecuritySpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/MigrationApiHttpServiceSecuritySpec.scala @@ -163,14 +163,15 @@ class MigrationApiHttpServiceSecuritySpec private def prepareRequestData(scenarioName: String, processCategory: TestCategory): String = s""" |{ - | "version": "1", + | "version": "2", | "sourceEnvironmentId": "$sourceEnvironmentId", | "remoteUserName": "remoteUser", | "processingMode": "Unbounded-Stream", | "engineSetupName": "Mockable", - | "processName": "${scenarioName}", + | "processName": "$scenarioName", | "isFragment": false, | "processCategory": "${processCategory.stringify}", + | "scenarioLabels": ["tag1", "tag2"], | "scenarioGraph": ${exampleGraph.asJson.noSpaces} |} |""".stripMargin diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ProcessesResourcesSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ProcessesResourcesSpec.scala index 0cd6bcd4fc7..b077c51697a 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ProcessesResourcesSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ProcessesResourcesSpec.scala @@ -17,7 +17,7 @@ import pl.touk.nussknacker.engine.api.ProcessAdditionalFields import pl.touk.nussknacker.engine.api.component.ProcessingMode import pl.touk.nussknacker.engine.api.deployment._ import pl.touk.nussknacker.engine.api.deployment.simple.{SimpleProcessStateDefinitionManager, SimpleStateStatus} -import pl.touk.nussknacker.engine.api.graph.ScenarioGraph +import pl.touk.nussknacker.engine.api.graph.{ProcessProperties, ScenarioGraph} import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} import pl.touk.nussknacker.engine.build.ScenarioBuilder import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess @@ -590,6 +590,93 @@ class ProcessesResourcesSpec } } + test("update scenario labels when the scenario does not have any") { + val properties = ProcessProperties( + ProcessAdditionalFields( + description = None, + properties = Map.empty, + metaDataType = "StreamMetaData" + ) + ) + val scenarioGraph = ScenarioGraph( + properties = properties, + nodes = List.empty, + edges = List.empty + ) + val command = UpdateScenarioCommand(scenarioGraph, None, Some(List("tag1", "tag2")), None) + + createProcessRequest(processName, category = Category1, isFragment = false) { code => + code shouldBe StatusCodes.Created + forScenarioReturned(processName) { scenario => + scenario.labels shouldBe List.empty[String] + } + doUpdateProcess(command, processName) { + forScenarioReturned(processName) { scenario => + scenario.labels shouldBe List("tag1", "tag2") + } + status shouldEqual StatusCodes.OK + } + } + } + + test("update scenario labels when the scenario does have some") { + val properties = ProcessProperties( + ProcessAdditionalFields( + description = None, + properties = Map.empty, + metaDataType = "StreamMetaData" + ) + ) + val scenarioGraph = ScenarioGraph( + properties = properties, + nodes = List.empty, + edges = List.empty + ) + + createProcessRequest(processName, category = Category1, isFragment = false) { code => + code shouldBe StatusCodes.Created + forScenarioReturned(processName) { scenario => + scenario.labels shouldBe List.empty[String] + } + val command1 = UpdateScenarioCommand( + scenarioGraph = scenarioGraph, + comment = None, + scenarioLabels = Some(List("tag2", "tag1")), + forwardedUserName = None + ) + doUpdateProcess(command1, processName) { + forScenarioReturned(processName) { scenario => + scenario.labels shouldBe List("tag1", "tag2") + } + status shouldEqual StatusCodes.OK + } + val command2 = UpdateScenarioCommand( + scenarioGraph = scenarioGraph, + comment = None, + scenarioLabels = Some(List("tag3", "tag1", "tag4")), + forwardedUserName = None + ) + doUpdateProcess(command2, processName) { + forScenarioReturned(processName) { scenario => + scenario.labels shouldBe List("tag1", "tag3", "tag4") + } + status shouldEqual StatusCodes.OK + } + val command3 = UpdateScenarioCommand( + scenarioGraph = scenarioGraph, + comment = None, + scenarioLabels = Some(List("tag3")), + forwardedUserName = None + ) + doUpdateProcess(command3, processName) { + forScenarioReturned(processName) { scenario => + scenario.labels shouldBe List("tag3") + } + status shouldEqual StatusCodes.OK + } + } + } + test("update process with the same json should add comment for current version") { val process = ProcessTestData.validProcess val comment = "Update the same version" @@ -1297,6 +1384,7 @@ class ProcessesResourcesSpec UpdateScenarioCommand( CanonicalProcessConverter.toScenarioGraph(process), comment.map(UpdateProcessComment(_)), + Some(List.empty), None ), process.name @@ -1354,7 +1442,12 @@ class ProcessesResourcesSpec private def updateProcess(process: ScenarioGraph, name: ProcessName = ProcessTestData.sampleProcessName)( testCode: => Assertion ): Assertion = - doUpdateProcess(UpdateScenarioCommand(process, comment = None, forwardedUserName = None), name)(testCode) + doUpdateProcess( + UpdateScenarioCommand(process, comment = None, scenarioLabels = Some(List.empty), forwardedUserName = None), + name + )( + testCode + ) private lazy val futureFetchingScenarioRepository: FetchingProcessRepository[Future] = TestFactory.newFutureFetchingScenarioRepository(testDbRef) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/RemoteEnvironmentResourcesSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/RemoteEnvironmentResourcesSpec.scala index 4d773ad826c..a25687aed04 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/RemoteEnvironmentResourcesSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/RemoteEnvironmentResourcesSpec.scala @@ -218,6 +218,7 @@ class RemoteEnvironmentResourcesSpec processingMode: ProcessingMode, engineSetupName: EngineSetupName, processCategory: String, + scenarioLabels: List[String], scenarioGraph: ScenarioGraph, processName: ProcessName, isFragment: Boolean diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ScenarioLabelsApiHttpServiceBusinessSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ScenarioLabelsApiHttpServiceBusinessSpec.scala new file mode 100644 index 00000000000..50db33e112b --- /dev/null +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ScenarioLabelsApiHttpServiceBusinessSpec.scala @@ -0,0 +1,230 @@ +package pl.touk.nussknacker.ui.api + +import io.circe.syntax._ +import io.restassured.RestAssured.`given` +import io.restassured.module.scala.RestAssuredSupport.AddThenToResponse +import org.scalatest.freespec.AnyFreeSpecLike +import pl.touk.nussknacker.engine.build.ScenarioBuilder +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.test.base.it.{NuItTest, WithSimplifiedConfigScenarioHelper} +import pl.touk.nussknacker.test.config.{ + WithBusinessCaseRestAssuredUsersExtensions, + WithMockableDeploymentManager, + WithSimplifiedDesignerConfig +} +import pl.touk.nussknacker.test.processes.WithScenarioActivitySpecAsserts +import pl.touk.nussknacker.test.{ + NuRestAssureExtensions, + NuRestAssureMatchers, + RestAssuredVerboseLoggingIfValidationFails +} +import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter + +import java.util.UUID + +class ScenarioLabelsApiHttpServiceBusinessSpec + extends AnyFreeSpecLike + with NuItTest + with WithSimplifiedDesignerConfig + with WithSimplifiedConfigScenarioHelper + with WithMockableDeploymentManager + with WithBusinessCaseRestAssuredUsersExtensions + with NuRestAssureExtensions + with WithScenarioActivitySpecAsserts + with NuRestAssureMatchers + with RestAssuredVerboseLoggingIfValidationFails { + + private val exampleScenarioName = UUID.randomUUID().toString + + private val exampleScenario = ScenarioBuilder + .streaming(exampleScenarioName) + .source("sourceId", "barSource") + .emptySink("sinkId", "barSink") + + private val otherExampleScenario = ScenarioBuilder + .streaming(UUID.randomUUID().toString) + .source("sourceId", "barSource") + .emptySink("sinkId", "barSink") + + "The scenario labels endpoint when" - { + "return no labels for existing process without them" in { + given() + .applicationState { + createSavedScenario(exampleScenario) + } + .when() + .basicAuthAllPermUser() + .get(s"$nuDesignerHttpAddress/api/scenarioLabels") + .Then() + .statusCode(200) + .equalsJsonBody( + s""" + |{ + | "labels": [] + |} + |""".stripMargin + ) + } + "return labels for all processes" in { + given() + .applicationState { + createSavedScenario(exampleScenario) + updateScenarioLabels(exampleScenario, List("tag2", "tag3")) + createSavedScenario(otherExampleScenario) + updateScenarioLabels(otherExampleScenario, List("tag1", "tag4")) + } + .when() + .basicAuthAllPermUser() + .get(s"$nuDesignerHttpAddress/api/scenarioLabels") + .Then() + .statusCode(200) + .equalsJsonBody( + s""" + |{ + | "labels": ["tag1", "tag2", "tag3", "tag4"] + |} + |""".stripMargin + ) + } + } + + "The scenario labels validation endpoint when" - { + "return no errors when" - { + "no labels passed" in { + given() + .when() + .basicAuthAllPermUser() + .body( + s""" + |{ + | "labels": [] + |}""".stripMargin + ) + .post(s"$nuDesignerHttpAddress/api/scenarioLabels/validation") + .Then() + .statusCode(200) + .equalsJsonBody( + s""" + |{ + | "validationErrors": [] + |} + |""".stripMargin + ) + } + "all labels are passing validation" in { + given() + .when() + .basicAuthAllPermUser() + .body( + s""" + |{ + | "labels": ["tag1", "tag2", "tag3"] + |}""".stripMargin + ) + .post(s"$nuDesignerHttpAddress/api/scenarioLabels/validation") + .Then() + .statusCode(200) + .equalsJsonBody( + s""" + |{ + | "validationErrors": [] + |} + |""".stripMargin + ) + } + } + "return validation errors when" - { + "some labels are not matching validation rules" in { + given() + .when() + .basicAuthAllPermUser() + .body( + s""" + |{ + | "labels": ["veryLongTag", "tag 1", "tag 12345678"] + |}""".stripMargin + ) + .post(s"$nuDesignerHttpAddress/api/scenarioLabels/validation") + .Then() + .statusCode(200) + .equalsJsonBody( + s""" + |{ + | "validationErrors": [ + | { + | "label": "veryLongTag", + | "messages": [ + | "Scenario label can contain up to 10 characters" + | ] + | }, + | { + | "label": "tag 1", + | "messages": [ + | "Scenario label can contain only alphanumeric characters, '-' and '_'" + | ] + | }, + | { + | "label": "tag 12345678", + | "messages": [ + | "Scenario label can contain only alphanumeric characters, '-' and '_'", + | "Scenario label can contain up to 10 characters" + | ] + | } + | ] + |} + |""".stripMargin + ) + } + "labels are duplicated" in { + given() + .when() + .basicAuthAllPermUser() + .body( + s""" + |{ + | "labels": ["tag1", "tag1"] + |}""".stripMargin + ) + .post(s"$nuDesignerHttpAddress/api/scenarioLabels/validation") + .Then() + .statusCode(200) + .equalsJsonBody( + s""" + |{ + | "validationErrors": [ + | { + | "label": "tag1", + | "messages": [ + | "Label has to be unique" + | ] + | } + | ] + |} + |""".stripMargin + ) + } + } + } + + private def updateScenarioLabels(scenario: CanonicalProcess, labels: List[String]): Unit = { + val scenarioName = scenario.metaData.id + val scenarioGraph = CanonicalProcessConverter.toScenarioGraph(scenario) + + given() + .when() + .jsonBody( + s""" + |{ + | "scenarioGraph": ${scenarioGraph.asJson.noSpaces}, + | "scenarioLabels": ${labels.asJson.noSpaces} + |} + |""".stripMargin + ) + .basicAuthAllPermUser() + .when() + .put(s"$nuDesignerHttpAddress/api/processes/$scenarioName") + .Then() + .statusCode(200) + } + +} diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ScenarioLabelsApiHttpServiceSecuritySpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ScenarioLabelsApiHttpServiceSecuritySpec.scala new file mode 100644 index 00000000000..666cd3a0839 --- /dev/null +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ScenarioLabelsApiHttpServiceSecuritySpec.scala @@ -0,0 +1,290 @@ +package pl.touk.nussknacker.ui.api + +import io.circe.syntax._ +import io.restassured.RestAssured.`given` +import io.restassured.module.scala.RestAssuredSupport.AddThenToResponse +import org.scalatest.freespec.AnyFreeSpecLike +import pl.touk.nussknacker.engine.build.ScenarioBuilder +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.test.{NuRestAssureMatchers, RestAssuredVerboseLoggingIfValidationFails} +import pl.touk.nussknacker.test.base.it.{ + NuItTest, + WithAccessControlCheckingConfigScenarioHelper, + WithSimplifiedConfigScenarioHelper +} +import pl.touk.nussknacker.test.config.WithAccessControlCheckingDesignerConfig.TestCategory.Category1 +import pl.touk.nussknacker.test.config.{ + WithAccessControlCheckingConfigRestAssuredUsersExtensions, + WithAccessControlCheckingDesignerConfig, + WithMockableDeploymentManager +} +import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter + +import java.util.UUID + +class ScenarioLabelsApiHttpServiceSecuritySpec + extends AnyFreeSpecLike + with NuItTest + with WithAccessControlCheckingDesignerConfig + with WithAccessControlCheckingConfigScenarioHelper + with WithMockableDeploymentManager + with WithAccessControlCheckingConfigRestAssuredUsersExtensions + with NuRestAssureMatchers + with RestAssuredVerboseLoggingIfValidationFails { + + "The scenario labels endpoint when" - { + "authenticated should" - { + "return all scenario labels" in { + val scenario1 = exampleScenario("s1") + val scenario2 = exampleScenario("s2") + + given() + .applicationState { + createSavedScenario(scenario1, category = Category1) + updateScenarioLabels(scenario1, List("tag2", "tag3")) + createSavedScenario(scenario2, category = Category1) + updateScenarioLabels(scenario2, List("tag1", "tag4")) + } + .when() + .basicAuthAdmin() + .get(s"$nuDesignerHttpAddress/api/scenarioLabels") + .Then() + .statusCode(200) + .equalsJsonBody( + s"""{ + | "labels": ["tag1", "tag2", "tag3", "tag4"] + |}""".stripMargin + ) + } + } + "not authenticated should" - { + "forbid access" in { + given() + .when() + .basicAuthUnknownUser() + .get(s"$nuDesignerHttpAddress/api/scenarioLabels") + .Then() + .statusCode(401) + } + } + "no credentials were passed should" - { + "authenticate as anonymous and return no labels for no write access" in { + val scenario1 = exampleScenario("s1") + val scenario2 = exampleScenario("s2") + + given() + .applicationState { + createSavedScenario(scenario1, category = Category1) + updateScenarioLabels(scenario1, List("tag2", "tag3")) + createSavedScenario(scenario2, category = Category1) + updateScenarioLabels(scenario2, List("tag1", "tag4")) + } + .when() + .noAuth() + .get(s"$nuDesignerHttpAddress/api/scenarioLabels") + .Then() + .statusCode(200) + .equalsJsonBody( + """{ + | "labels": [] + |}""".stripMargin + ) + } + } + "impersonating user has permission to impersonate should" - { + "return all scenario labels" in { + val scenario1 = exampleScenario("s1") + val scenario2 = exampleScenario("s2") + + given() + .applicationState { + createSavedScenario(scenario1, category = Category1) + updateScenarioLabels(scenario1, List("tag2", "tag3")) + createSavedScenario(scenario2, category = Category1) + updateScenarioLabels(scenario2, List("tag1", "tag4")) + } + .when() + .basicAuthAllPermUser() + .impersonateLimitedWriterUser() + .get(s"$nuDesignerHttpAddress/api/scenarioLabels") + .Then() + .statusCode(200) + .equalsJsonBody( + s"""{ + | "labels": ["tag1", "tag2", "tag3", "tag4"] + |}""".stripMargin + ) + } + } + "impersonating user does not have permission to impersonate should" - { + "forbid access" in { + given() + .when() + .basicAuthWriter() + .impersonateLimitedWriterUser() + .get(s"$nuDesignerHttpAddress/api/scenarioLabels") + .Then() + .statusCode(403) + .equalsPlainBody("The supplied authentication is not authorized to impersonate") + } + } + } + + "The scenario labels validation endpoint when" - { + "authenticated should" - { + "validate scenario labels" in { + given() + .when() + .basicAuthAdmin() + .body( + s""" + |{ + | "labels": ["tag12345678", "tag2"] + |}""".stripMargin + ) + .post(s"$nuDesignerHttpAddress/api/scenarioLabels/validation") + .Then() + .statusCode(200) + .equalsJsonBody( + s""" + |{ + | "validationErrors": [ + | { + | "label": "tag12345678", + | "messages": [ + | "Scenario label can contain up to 10 characters" + | ] + | } + | ] + |} + |""".stripMargin + ) + } + } + "not authenticated should" - { + "forbid access" in { + given() + .when() + .basicAuthUnknownUser() + .body( + s""" + |{ + | "labels": ["tag1", "tag2"] + |}""".stripMargin + ) + .post(s"$nuDesignerHttpAddress/api/scenarioLabels/validation") + .Then() + .statusCode(401) + } + } + "no credentials were passed should" - { + "authenticate as anonymous and validate labels" in { + given() + .when() + .noAuth() + .body( + s""" + |{ + | "labels": ["tag12345678", "tag2"] + |}""".stripMargin + ) + .post(s"$nuDesignerHttpAddress/api/scenarioLabels/validation") + .Then() + .statusCode(200) + .equalsJsonBody( + s""" + |{ + | "validationErrors": [ + | { + | "label": "tag12345678", + | "messages": [ + | "Scenario label can contain up to 10 characters" + | ] + | } + | ] + |} + |""".stripMargin + ) + } + } + "impersonating user has permission to impersonate should" - { + "validate labels" in { + val scenario1 = exampleScenario("s1") + val scenario2 = exampleScenario("s2") + + given() + .when() + .basicAuthAllPermUser() + .impersonateLimitedWriterUser() + .body( + s""" + |{ + | "labels": ["tag12345678", "tag2"] + |}""".stripMargin + ) + .post(s"$nuDesignerHttpAddress/api/scenarioLabels/validation") + .Then() + .statusCode(200) + .equalsJsonBody( + s""" + |{ + | "validationErrors": [ + | { + | "label": "tag12345678", + | "messages": [ + | "Scenario label can contain up to 10 characters" + | ] + | } + | ] + |} + |""".stripMargin + ) + } + } + "impersonating user does not have permission to impersonate should" - { + "forbid access" in { + given() + .when() + .basicAuthWriter() + .impersonateLimitedWriterUser() + .body( + s""" + |{ + | "labels": ["tag1", "tag2"] + |}""".stripMargin + ) + .post(s"$nuDesignerHttpAddress/api/scenarioLabels/validation") + .Then() + .statusCode(403) + .equalsPlainBody("The supplied authentication is not authorized to impersonate") + } + } + } + + private def exampleScenario(scenarioName: String) = ScenarioBuilder + .streaming(scenarioName) + .source("sourceId", "barSource") + .emptySink("sinkId", "barSink") + + private def updateScenarioLabels(scenario: CanonicalProcess, labels: List[String]): Unit = { + val scenarioName = scenario.metaData.id + val scenarioGraph = CanonicalProcessConverter.toScenarioGraph(scenario) + + given() + .when() + .jsonBody( + s""" + |{ + | "scenarioGraph": ${scenarioGraph.asJson.noSpaces}, + | "scenarioLabels": ${labels.asJson.noSpaces} + |} + |""".stripMargin + ) + .basicAuthAllPermUser() + .when() + .put(s"$nuDesignerHttpAddress/api/processes/$scenarioName") + .Then() + .statusCode(200) + } + +} diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ValidationResourcesSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ValidationResourcesSpec.scala index 8a39d08680a..0654b8d9491 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ValidationResourcesSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ValidationResourcesSpec.scala @@ -50,7 +50,7 @@ class ValidationResourcesSpec private val processValidatorByProcessingType = mapProcessingTypeDataProvider( Streaming.stringify -> new UIProcessResolver( - TestFactory.processValidator.withScenarioPropertiesConfig( + ProcessTestData.testProcessValidator(scenarioProperties = Map( "requiredStringProperty" -> ScenarioPropertyConfig( None, diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/initialization/InitializationOnDbItSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/initialization/InitializationOnDbItSpec.scala index 23475150e28..9c6db097444 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/initialization/InitializationOnDbItSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/initialization/InitializationOnDbItSpec.scala @@ -36,6 +36,8 @@ abstract class InitializationOnDbItSpec private lazy val commentRepository = TestFactory.newCommentRepository(testDbRef) + private lazy val scenarioLabelsRepository = TestFactory.newScenarioLabelsRepository(testDbRef) + private lazy val scenarioRepository = TestFactory.newFetchingProcessRepository(testDbRef) private lazy val dbioRunner = TestFactory.newDBIOActionRunner(testDbRef) @@ -47,7 +49,7 @@ abstract class InitializationOnDbItSpec it should "migrate processes" in { saveSampleProcess() - Initialization.init(migrations, testDbRef, scenarioRepository, commentRepository, "env1") + Initialization.init(migrations, testDbRef, scenarioRepository, commentRepository, scenarioLabelsRepository, "env1") dbioRunner .runInTransaction( @@ -66,7 +68,7 @@ abstract class InitializationOnDbItSpec saveSampleProcess(ProcessName(s"id$id")) } - Initialization.init(migrations, testDbRef, scenarioRepository, commentRepository, "env1") + Initialization.init(migrations, testDbRef, scenarioRepository, commentRepository, scenarioLabelsRepository, "env1") dbioRunner .runInTransaction( @@ -86,6 +88,7 @@ abstract class InitializationOnDbItSpec testDbRef, scenarioRepository, commentRepository, + scenarioLabelsRepository, "env1" ) ) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/marshall/UiProcessMarshallerSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/marshall/UiProcessMarshallerSpec.scala index 6ae3599269f..5d2b5529757 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/marshall/UiProcessMarshallerSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/marshall/UiProcessMarshallerSpec.scala @@ -44,19 +44,19 @@ class UiProcessMarshallerSpec extends AnyFlatSpec with Matchers { def processWithFullAdditionalFields(name: ProcessName): Json = parse(s""" |{ | "metaData" : { - | "id" : "$name", - | "additionalFields" : { - | "description": "$someProcessDescription", - | "properties" : { - | "someProperty1": "", - | "someProperty2": "someValue2", - | "parallelism" : "1", - | "spillStateToDisk" : "true", - | "useAsyncInterpretation" : "", - | "checkpointIntervalInSeconds" : "" - | }, - | "metaDataType": "StreamMetaData", - | "showDescription": false + | "id" : "$name", + | "additionalFields" : { + | "description": "$someProcessDescription", + | "properties" : { + | "someProperty1": "", + | "someProperty2": "someValue2", + | "parallelism" : "1", + | "spillStateToDisk" : "true", + | "useAsyncInterpretation" : "", + | "checkpointIntervalInSeconds" : "" + | }, + | "metaDataType": "StreamMetaData", + | "showDescription": false | } | }, | "nodes" : [ @@ -90,7 +90,7 @@ class UiProcessMarshallerSpec extends AnyFlatSpec with Matchers { val processAfterMarshallAndUnmarshall = canonical.asJson.printWith(humanReadablePrinter) - parse(processAfterMarshallAndUnmarshall).toOption.get shouldBe baseProcess + parse(processAfterMarshallAndUnmarshall) shouldBe Right(baseProcess) } } diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/migrate/StandardRemoteEnvironmentSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/migrate/StandardRemoteEnvironmentSpec.scala index fb119dfccfe..86a91591b5d 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/migrate/StandardRemoteEnvironmentSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/migrate/StandardRemoteEnvironmentSpec.scala @@ -16,7 +16,11 @@ import pl.touk.nussknacker.test.utils.domain.TestProcessUtil.wrapGraphWithScenar import pl.touk.nussknacker.test.utils.domain.{ProcessTestData, TestProcessUtil} import pl.touk.nussknacker.test.{EitherValuesDetailedMessage, PatientScalaFutures} import pl.touk.nussknacker.ui.NuDesignerError -import pl.touk.nussknacker.ui.api.description.MigrationApiEndpoints.Dtos.{ApiVersion, MigrateScenarioRequestDtoV1} +import pl.touk.nussknacker.ui.api.description.MigrationApiEndpoints.Dtos.{ + ApiVersion, + MigrateScenarioRequestDtoV1, + MigrateScenarioRequestDtoV2 +} import pl.touk.nussknacker.ui.migrations.{MigrateScenarioData, MigrationApiAdapterService} import pl.touk.nussknacker.ui.process.ScenarioWithDetailsConversions import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter @@ -108,15 +112,7 @@ class StandardRemoteEnvironmentSpec } - /* - - NOTE TO DEVELOPER: - - These test cases are currently commented out. They should be enabled when MigrateScenarioRequestDtoV2 will be present - - Remember to uncomment the test case(s) after review and modification. - - it should "request to migrate valid scenario when remote scenario description version is lower than local scenario description version" in { + it should "request to migrate valid scenario when remote scenario description version is lower than local scenario description version" in { val localScenarioDescriptionVersion = migrationApiAdapterService.getCurrentApiVersion val remoteScenarioDescriptionVersion = localScenarioDescriptionVersion - 1 val remoteEnvironment: MockRemoteEnvironment with LastSentMigrateScenarioRequest = @@ -127,6 +123,7 @@ class StandardRemoteEnvironmentSpec ProcessTestData.sampleScenarioParameters.processingMode, ProcessTestData.sampleScenarioParameters.engineSetupName, ProcessTestData.sampleScenarioParameters.category, + ProcessTestData.sampleScenarioLabels, ProcessTestData.validScenarioGraph, ProcessTestData.sampleProcessName, false @@ -139,9 +136,9 @@ class StandardRemoteEnvironmentSpec case _ => fail("lastly sent migrate scenario request should be non empty") } } - }*/ + } - /* it should "request to migrate valid scenario when remote scenario description version is the same as local scenario description version" in { + it should "request to migrate valid scenario when remote scenario description version is the same as local scenario description version" in { val localScenarioDescriptionVersion = migrationApiAdapterService.getCurrentApiVersion val remoteEnvironment: MockRemoteEnvironment with LastSentMigrateScenarioRequest = remoteEnvironmentMock(scenarioDescriptionVersion = localScenarioDescriptionVersion) @@ -151,6 +148,7 @@ class StandardRemoteEnvironmentSpec ProcessTestData.sampleScenarioParameters.processingMode, ProcessTestData.sampleScenarioParameters.engineSetupName, ProcessTestData.sampleScenarioParameters.category, + ProcessTestData.sampleScenarioLabels, ProcessTestData.validScenarioGraph, ProcessTestData.sampleProcessName, false @@ -163,9 +161,9 @@ class StandardRemoteEnvironmentSpec case _ => fail("lastly sent migrate scenario request should be non empty") } } - }*/ + } - /* it should "request to migrate valid scenario when remote scenario description version is higher than local scenario description version" in { + it should "request to migrate valid scenario when remote scenario description version is higher than local scenario description version" in { val localScenarioDescriptionVersion = migrationApiAdapterService.getCurrentApiVersion val remoteScenarioDescriptionVersion = localScenarioDescriptionVersion + 1 val remoteEnvironment: MockRemoteEnvironment with LastSentMigrateScenarioRequest = @@ -176,6 +174,7 @@ class StandardRemoteEnvironmentSpec ProcessTestData.sampleScenarioParameters.processingMode, ProcessTestData.sampleScenarioParameters.engineSetupName, ProcessTestData.sampleScenarioParameters.category, + ProcessTestData.sampleScenarioLabels, ProcessTestData.validScenarioGraph, ProcessTestData.sampleProcessName, false @@ -188,7 +187,7 @@ class StandardRemoteEnvironmentSpec case _ => fail("lastly sent migrate scenario request should be non empty") } } - }*/ + } it should "test migration" in { val remoteEnvironment = environmentForTestMigration( @@ -272,13 +271,7 @@ class StandardRemoteEnvironmentSpec HttpResponse(OK, entity = entity) } case Migrate() => - /* - - NOTE TO DEVELOPER: - - This mock code block is currently commented out. It should be enabled when MigrateScenarioRequestV2 will be prsent - - parseBodyToJson(request).as[MigrateScenarioRequestDtoV2] match { + parseBodyToJson(request).as[MigrateScenarioRequestDtoV2] match { case Right(migrateScenarioRequestDtoV2) if migrateScenarioRequestDtoV2.version == 2 => lastlySentMigrateScenarioRequest = Some( MigrateScenarioData.toDomain(migrateScenarioRequestDtoV2).rightValue @@ -299,7 +292,7 @@ class StandardRemoteEnvironmentSpec ) case Left(_) => lastlySentMigrateScenarioRequest = None } - }*/ + } Marshal(Right[NuDesignerError, Unit](())).to[RequestEntity].map { entity => HttpResponse(OK, entity = entity) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/migrate/TestModelMigrationsSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/migrate/TestModelMigrationsSpec.scala index c538d7ac4dc..e4ec705b344 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/migrate/TestModelMigrationsSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/migrate/TestModelMigrationsSpec.scala @@ -99,7 +99,12 @@ class TestModelMigrationsSpec extends AnyFunSuite with Matchers { ) val validationResult = - flinkProcessValidator.validate(invalidGraph, ProcessTestData.sampleProcessName, isFragment = false) + flinkProcessValidator.validate( + invalidGraph, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = List.empty + ) val process = wrapWithDetailsForMigration(invalidGraph, validationResult = validationResult) val results = testMigration.testMigrations(List(process), List(), batchingExecutionContext) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepositorySpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepositorySpec.scala index 5338b737c3a..28dbbd453c3 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepositorySpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepositorySpec.scala @@ -42,8 +42,15 @@ class DBFetchingProcessRepositorySpec private val commentRepository = new CommentRepository(testDbRef) + private val scenarioLabelsRepository = new ScenarioLabelsRepository(testDbRef) + private val writingRepo = - new DBProcessRepository(testDbRef, commentRepository, mapProcessingTypeDataProvider("Streaming" -> 0)) { + new DBProcessRepository( + testDbRef, + commentRepository, + scenarioLabelsRepository, + mapProcessingTypeDataProvider("Streaming" -> 0) + ) { override protected def now: Instant = currentTime } @@ -56,7 +63,8 @@ class DBFetchingProcessRepositorySpec ProcessingTypeDataProvider.withEmptyCombinedData(Map.empty) ) - private val fetching = DBFetchingProcessRepository.createFutureRepository(testDbRef, actions) + private val fetching = + DBFetchingProcessRepository.createFutureRepository(testDbRef, actions, scenarioLabelsRepository) private val activities = DbProcessActivityRepository(testDbRef, commentRepository) @@ -297,6 +305,7 @@ class DBFetchingProcessRepositorySpec processId, canonicalProcess, comment = None, + labels = List.empty, increaseVersionWhenJsonNotChanged, forwardedUserName = None ) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/validation/UIProcessValidatorSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/validation/UIProcessValidatorSpec.scala index 3f6b45bc137..17d5d56a046 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/validation/UIProcessValidatorSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/validation/UIProcessValidatorSpec.scala @@ -81,8 +81,10 @@ import pl.touk.nussknacker.test.mock.{ } import pl.touk.nussknacker.test.utils.domain.ProcessTestData._ import pl.touk.nussknacker.test.utils.domain.{ProcessTestData, TestFactory} +import pl.touk.nussknacker.ui.config.ScenarioLabelConfig import pl.touk.nussknacker.ui.definition.ScenarioPropertiesConfigFinalizer import pl.touk.nussknacker.ui.process.fragment.FragmentResolver +import pl.touk.nussknacker.ui.process.label.ScenarioLabel import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter import pl.touk.nussknacker.ui.security.api.{AdminUser, LoggedUser} @@ -111,7 +113,6 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP private val validationExpressionForListWithMetadata = Expression.spel(s"#value[0] == #meta.processName.toLowerCase") - private val validationExpressionForLocalDateTime = Expression.spel( s"""#${ValidationExpressionParameterValidator.variableName}.dayOfWeek.name == 'FRIDAY'""" @@ -333,9 +334,130 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP result.warnings shouldBe ValidationWarnings.success } + test("validate scenario labels") { + val scenario = { + createGraph( + List( + Source("in", SourceRef(ProcessTestData.existingSourceFactory, List())), + Sink("out", SinkRef(ProcessTestData.existingSinkFactory, List())) + ), + List(Edge("in", "out", None)), + ) + } + + def createValidator(config: Option[ScenarioLabelConfig]) = { + ProcessTestData.testProcessValidator( + scenarioLabelsValidator = new ScenarioLabelsValidator(config), + scenarioProperties = FlinkStreamingPropertiesConfig.properties + ) + } + + def validate(processValidator: UIProcessValidator, labels: List[String]) = { + processValidator.validate( + scenario, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = labels.map(ScenarioLabel.apply) + ) + } + + val validatorWithoutConfig = createValidator(None) + + validate(validatorWithoutConfig, List.empty) shouldBe withoutErrorsAndWarnings + validate(validatorWithoutConfig, List("tag1", "tag2", "tag100", "tag 100")) shouldBe withoutErrorsAndWarnings + + val validatorWithNoValidationRules = createValidator(Some(ScenarioLabelConfig(validationRules = List.empty))) + + validate(validatorWithNoValidationRules, List.empty) shouldBe withoutErrorsAndWarnings + validate( + validatorWithNoValidationRules, + List("tag1", "tag2", "tag100", "tag 100") + ) shouldBe withoutErrorsAndWarnings + + val validator = createValidator( + Some( + ScenarioLabelConfig( + validationRules = List( + ScenarioLabelConfig.ValidationRule("^[a-zA-z0-9]*$".r, "Only alphanumeric characters allowed"), + ScenarioLabelConfig.ValidationRule(".{0,5}$$".r, "Label '{label}' cannot have more than 5 characters"), + ) + ) + ) + ) + + validate(validator, List.empty) shouldBe withoutErrorsAndWarnings + validate(validator, List("tag1", "tag2")) shouldBe withoutErrorsAndWarnings + validate(validator, List("tag100", "tag2")).errors.globalErrors shouldBe List( + UIGlobalError( + NodeValidationError( + typ = "ScenarioLabelValidationError", + message = "Invalid scenario label: tag100", + description = "Label 'tag100' cannot have more than 5 characters", + fieldName = Some("tag100"), + errorType = NodeValidationErrorType.SaveAllowed, + details = None + ), + List.empty + ) + ) + + validate(validator, List("tag 1", "tag2")).errors.globalErrors shouldBe List( + UIGlobalError( + NodeValidationError( + typ = "ScenarioLabelValidationError", + message = "Invalid scenario label: tag 1", + description = "Only alphanumeric characters allowed", + fieldName = Some("tag 1"), + errorType = NodeValidationErrorType.SaveAllowed, + details = None + ), + List.empty + ) + ) + + validate(validator, List("tag 100", "tag2")).errors.globalErrors shouldBe List( + UIGlobalError( + NodeValidationError( + typ = "ScenarioLabelValidationError", + message = "Invalid scenario label: tag 100", + description = "Only alphanumeric characters allowed, Label 'tag 100' cannot have more than 5 characters", + fieldName = Some("tag 100"), + errorType = NodeValidationErrorType.SaveAllowed, + details = None + ), + List.empty + ) + ) + + validate(validator, List("tag 100", "tag 200", "tag2")).errors.globalErrors shouldBe List( + UIGlobalError( + NodeValidationError( + typ = "ScenarioLabelValidationError", + message = "Invalid scenario label: tag 100", + description = "Only alphanumeric characters allowed, Label 'tag 100' cannot have more than 5 characters", + fieldName = Some("tag 100"), + errorType = NodeValidationErrorType.SaveAllowed, + details = None + ), + List.empty + ), + UIGlobalError( + NodeValidationError( + typ = "ScenarioLabelValidationError", + message = "Invalid scenario label: tag 200", + description = "Only alphanumeric characters allowed, Label 'tag 200' cannot have more than 5 characters", + fieldName = Some("tag 200"), + errorType = NodeValidationErrorType.SaveAllowed, + details = None + ), + List.empty + ) + ) + } + test("validate missing required scenario properties") { - val processValidator = TestFactory.processValidator.withScenarioPropertiesConfig( - Map( + val processValidator = ProcessTestData.testProcessValidator( + scenarioProperties = Map( "field1" -> ScenarioPropertyConfig( defaultValue = None, editor = None, @@ -354,7 +476,12 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) def validate(scenarioGraph: ScenarioGraph) = - processValidator.validate(scenarioGraph, ProcessTestData.sampleProcessName, isFragment = false) + processValidator.validate( + scenarioGraph, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = List.empty + ) validate( validScenarioGraphWithFields(Map("field1" -> "a", "field2" -> "b")) @@ -392,10 +519,15 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP test("validate scenario properties according to validators from additional config provider") { val processValidator = - TestFactory.processValidator.withScenarioPropertiesConfig(FlinkStreamingPropertiesConfig.properties) + ProcessTestData.testProcessValidator(scenarioProperties = FlinkStreamingPropertiesConfig.properties) def validate(scenarioGraph: ScenarioGraph) = - processValidator.validate(scenarioGraph, ProcessTestData.sampleProcessName, isFragment = false) + processValidator.validate( + scenarioGraph, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = List.empty + ) validate( validScenarioGraphWithFields( @@ -424,8 +556,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP } test("don't validate properties on fragment") { - val processValidator = TestFactory.processValidator.withScenarioPropertiesConfig( - Map( + val processValidator = ProcessTestData.testProcessValidator( + scenarioProperties = Map( "field1" -> ScenarioPropertyConfig( defaultValue = None, editor = None, @@ -446,15 +578,16 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP processValidator.validate( validScenarioGraphWithFields(Map.empty), ProcessTestData.sampleFragmentName, - isFragment = true + isFragment = true, + labels = List.empty, ) shouldBe withoutErrorsAndWarnings } test("validate type scenario field") { val possibleValues = List(FixedExpressionValue("true", "true"), FixedExpressionValue("false", "false")) - val processValidator = TestFactory.processValidator.withScenarioPropertiesConfig( - Map( + val processValidator = ProcessTestData.testProcessValidator( + scenarioProperties = Map( "field1" -> ScenarioPropertyConfig( defaultValue = None, editor = Some(FixedValuesParameterEditor(possibleValues)), @@ -472,7 +605,12 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ++ FlinkStreamingPropertiesConfig.properties ) def validate(scenarioGraph: ScenarioGraph) = - processValidator.validate(scenarioGraph, ProcessTestData.sampleProcessName, isFragment = false) + processValidator.validate( + scenarioGraph, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = List.empty + ) validate(validScenarioGraphWithFields(Map("field1" -> "true"))) shouldBe withoutErrorsAndWarnings validate(validScenarioGraphWithFields(Map("field1" -> "false"))) shouldBe withoutErrorsAndWarnings @@ -484,8 +622,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP } test("handle unknown properties validation") { - val processValidator = TestFactory.processValidator.withScenarioPropertiesConfig( - Map( + val processValidator = ProcessTestData.testProcessValidator( + scenarioProperties = Map( "field2" -> ScenarioPropertyConfig( defaultValue = None, editor = None, @@ -500,7 +638,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP processValidator.validate( validScenarioGraphWithFields(Map("field1" -> "true")), ProcessTestData.sampleProcessName, - isFragment = false + isFragment = false, + labels = List.empty, ) result.errors.processPropertiesErrors should matchPattern { @@ -747,7 +886,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP val validationResult = processValidatorWithDicts( Map("someDictId" -> EmbeddedDictDefinition(Map.empty)) - ).validate(fragmentGraph, sampleProcessName, isFragment = true) + ).validate(fragmentGraph, sampleProcessName, isFragment = true, labels = List.empty) validationResult.errors should not be empty validationResult.errors.invalidNodes("in") should matchPattern { @@ -879,7 +1018,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) val processValidator = mockedProcessValidator(Some(invalidFragment)) - val validationResult = processValidator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val validationResult = + processValidator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) validationResult should matchPattern { case ValidationResult(ValidationErrors(invalidNodes, Nil, Nil), ValidationWarnings.success, _) @@ -950,7 +1090,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) val processValidation = mockedProcessValidator(Some(fragment)) - val validationResult = processValidation.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val validationResult = + processValidation.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) validationResult.errors should not be empty validationResult.errors.invalidNodes("subIn1") should matchPattern { @@ -1010,7 +1151,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP val processValidator = mockedProcessValidator(Some(invalidFragment)) - val validationResult = processValidator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val validationResult = + processValidator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) validationResult.errors.invalidNodes shouldBe Symbol("empty") validationResult.errors.globalErrors shouldBe Symbol("empty") validationResult.saveAllowed shouldBe true @@ -1057,7 +1199,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) val processValidator = mockedProcessValidator(Some(fragment)) - val validationResult = processValidator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val validationResult = + processValidator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) validationResult.errors.invalidNodes shouldBe Symbol("empty") validationResult.nodeResults("sink2").variableTypes("input") shouldBe typing.Unknown @@ -1119,7 +1262,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ) - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1155,7 +1298,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ) - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1191,7 +1334,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ) - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1227,7 +1370,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ) - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1276,7 +1419,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ) - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1325,7 +1468,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ) - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1378,7 +1521,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ) - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1398,7 +1541,6 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP result.warnings shouldBe ValidationWarnings.success } - test( "validate List service parameter based on additional config from provider - ValidationExpressionParameterValidator with #meta variable" ) { @@ -1432,7 +1574,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ) - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1480,7 +1622,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ) - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1518,13 +1660,14 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP scenarioProperties = Map.empty, scenarioPropertiesConfigFinalizer = new ScenarioPropertiesConfigFinalizer(TestAdditionalUIConfigProvider, Streaming.stringify), + scenarioLabelsValidator = new ScenarioLabelsValidator(config = None), additionalValidators = List.empty, fragmentResolver = new FragmentResolver(new StubFragmentRepository(Map.empty)) ) val process = processWithOptionalParameterService("") - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1575,13 +1718,14 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP scenarioProperties = Map.empty, scenarioPropertiesConfigFinalizer = new ScenarioPropertiesConfigFinalizer(TestAdditionalUIConfigProvider, Streaming.stringify), + scenarioLabelsValidator = new ScenarioLabelsValidator(config = None), additionalValidators = List.empty, fragmentResolver = new FragmentResolver(new StubFragmentRepository(Map.empty)) ) val process = processWithOptionalParameterService("'Barabasz'") - val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val result = validator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1609,7 +1753,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) ) - val result = processValidatorWithDicts(Map.empty).validate(process, sampleProcessName, isFragment = false) + val result = + processValidatorWithDicts(Map.empty).validate(process, sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1632,7 +1777,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP test("checks for unknown dictId in DictParameterEditor") { val process = processWithDictParameterEditorService(Expression.dictKeyWithLabel("someKey", Some("someLabel"))) - val result = processValidatorWithDicts(Map.empty).validate(process, sampleProcessName, isFragment = false) + val result = + processValidatorWithDicts(Map.empty).validate(process, sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1658,7 +1804,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP val result = processValidatorWithDicts( Map("someDictId" -> EmbeddedDictDefinition(Map.empty)) - ).validate(process, sampleProcessName, isFragment = false) + ).validate(process, sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("custom") should matchPattern { @@ -1683,7 +1829,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP val result = processValidatorWithDicts( Map("someDictId" -> EmbeddedDictDefinition(Map("someKey" -> "someLabel"))) - ).validate(process, sampleProcessName, isFragment = false) + ).validate(process, sampleProcessName, isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe empty result.errors.invalidNodes shouldBe Map.empty @@ -1772,7 +1918,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP val processValidator = mockedProcessValidator(Some(fragment)) - val validationResult = processValidator.validate(process, ProcessTestData.sampleProcessName, isFragment = false) + val validationResult = + processValidator.validate(process, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) validationResult.errors.invalidNodes shouldBe Symbol("empty") validationResult.errors.globalErrors shouldBe Symbol("empty") validationResult.saveAllowed shouldBe true @@ -1784,7 +1931,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP processValidatorWithFragmentInAnotherProcessingType.validate( process, ProcessTestData.sampleProcessName, - isFragment = false + isFragment = false, + labels = List.empty ) validationResultWithCategory2.errors.invalidNodes shouldBe Map( "subIn" -> List(PrettyValidationErrors.formatErrorMessage(UnknownFragment(fragment.name.value, "subIn"))) @@ -1806,7 +1954,12 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP createScenarioGraphWithFragmentParams(fragmentId, List(NodeParameter(ParameterName("P1"), "123".spel))) val processValidator = mockedProcessValidator(Some(fragmentDefinition), configWithValidators) - val result = processValidator.validate(processWithFragment, ProcessTestData.sampleProcessName, isFragment = false) + val result = processValidator.validate( + processWithFragment, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = List.empty + ) result.hasErrors shouldBe false result.errors.invalidNodes shouldBe Symbol("empty") result.errors.globalErrors shouldBe Symbol("empty") @@ -1825,7 +1978,12 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP createScenarioGraphWithFragmentParams(fragmentId, List(NodeParameter(ParameterName("P1"), "".spel))) val processValidator = mockedProcessValidator(Some(fragmentDefinition), defaultConfig) - val result = processValidator.validate(processWithFragment, ProcessTestData.sampleProcessName, isFragment = false) + val result = processValidator.validate( + processWithFragment, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = List.empty + ) result.hasErrors shouldBe true result.errors.globalErrors shouldBe empty @@ -1867,7 +2025,12 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) val processValidator = mockedProcessValidator(Some(fragmentDefinition), defaultConfig) - val result = processValidator.validate(processWithFragment, ProcessTestData.sampleProcessName, isFragment = false) + val result = processValidator.validate( + processWithFragment, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = List.empty + ) result.hasErrors shouldBe true result.errors.globalErrors shouldBe empty @@ -1920,7 +2083,12 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) val processValidation = mockedProcessValidator(Some(fragmentDefinition), defaultConfig) - val result = processValidation.validate(processWithFragment, ProcessTestData.sampleProcessName, isFragment = false) + val result = processValidation.validate( + processWithFragment, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = List.empty + ) result.hasErrors shouldBe false result.errors.invalidNodes shouldBe Symbol("empty") @@ -1957,7 +2125,12 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) val processValidation = mockedProcessValidator(Some(fragmentDefinition), configWithValidators) - val result = processValidation.validate(processWithFragment, ProcessTestData.sampleProcessName, isFragment = false) + val result = processValidation.validate( + processWithFragment, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = List.empty + ) result.hasErrors shouldBe true result.errors.globalErrors shouldBe empty result.errors.invalidNodes.get("subIn") should matchPattern { @@ -1987,7 +2160,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP mockedProcessValidator(Some(process)).validate( scenarioGraph, SampleCustomProcessValidator.badName, - isFragment = false + isFragment = false, + labels = List.empty ) result.errors.processPropertiesErrors shouldBe List( @@ -1998,7 +2172,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP test("should validate invalid scenario id") { val blankValue = ProcessName(" ") val result = TestFactory.flinkProcessValidator - .validate(UIProcessValidatorSpec.validFlinkScenarioGraph, blankValue, isFragment = false) + .validate(UIProcessValidatorSpec.validFlinkScenarioGraph, blankValue, isFragment = false, labels = List.empty) .errors .processPropertiesErrors result shouldBe List( @@ -2016,7 +2190,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP List(Edge(blankValue, "out", None)) ) val result = TestFactory.flinkProcessValidator - .validate(testedScenario, ProcessTestData.sampleProcessName, isFragment = false) + .validate(testedScenario, ProcessTestData.sampleProcessName, isFragment = false, labels = List.empty) .errors .invalidNodes val nodeErrors = @@ -2032,7 +2206,12 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP List.empty ) val result = - TestFactory.flinkProcessValidator.validate(incompleteScenarioWithBlankIds, ProcessName(" "), isFragment = false) + TestFactory.flinkProcessValidator.validate( + incompleteScenarioWithBlankIds, + ProcessName(" "), + isFragment = false, + labels = List.empty + ) inside(result) { case ValidationResult(errors, _, _) => inside(errors) { case ValidationErrors(nodeErrors, propertiesErrors, _) => nodeErrors should contain key " " @@ -2056,7 +2235,12 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP fragmentOutput("outNode2", duplicatedOutputName), ) val scenarioGraph = CanonicalProcessConverter.toScenarioGraph(fragment) - val result = TestFactory.flinkProcessValidator.validate(scenarioGraph, ProcessName(" "), isFragment = true) + val result = TestFactory.flinkProcessValidator.validate( + scenarioGraph, + ProcessName(" "), + isFragment = true, + labels = List.empty + ) result.errors.globalErrors shouldBe List( UIGlobalError( PrettyValidationErrors.formatErrorMessage( @@ -2085,7 +2269,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP val scenarioGraph = CanonicalProcessConverter.toScenarioGraph(scenario) val processValidator = mockedProcessValidator(Some(fragment), defaultConfig) - val result = processValidator.validate(scenarioGraph, ProcessName(" "), isFragment = true) + val result = processValidator.validate(scenarioGraph, ProcessName(" "), isFragment = true, labels = List.empty) result.errors.invalidNodes shouldBe Map( "fragment" -> List( @@ -2131,7 +2315,7 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP .split("split", GraphBuilder.split(invalidEndingNodeId1), GraphBuilder.split(invalidEndingNodeId2)) val scenarioGraph = CanonicalProcessConverter.toScenarioGraph(scenario) - val result = processValidator.validate(scenarioGraph, ProcessName("name"), isFragment = false) + val result = processValidator.validate(scenarioGraph, ProcessName("name"), isFragment = false, labels = List.empty) inside(result.errors.globalErrors) { case UIGlobalError(error, nodeIds) :: Nil => error shouldBe PrettyValidationErrors.formatErrorMessage( @@ -2195,7 +2379,8 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP ) val processValidator = mockedProcessValidator(Some(fragment), defaultConfig) - val result = processValidator.validate(processWithFragment, ProcessName("name"), isFragment = false) + val result = + processValidator.validate(processWithFragment, ProcessName("name"), isFragment = false, labels = List.empty) val nodeVariableTypes = result.nodeResults.mapValuesNow(_.variableTypes) nodeVariableTypes.get("sink").value shouldEqual Map("input" -> Unknown) @@ -2203,14 +2388,19 @@ class UIProcessValidatorSpec extends AnyFunSuite with Matchers with TableDrivenP test("return empty process error for empty scenario") { val emptyScenario = createGraph(List.empty, List.empty) - val result = processValidator.validate(emptyScenario, ProcessName("name"), isFragment = false) + val result = processValidator.validate(emptyScenario, ProcessName("name"), isFragment = false, labels = List.empty) result.errors.globalErrors shouldBe List( UIGlobalError(PrettyValidationErrors.formatErrorMessage(EmptyProcess), List.empty) ) } private def validate(scenarioGraph: ScenarioGraph): ValidationResult = { - TestFactory.processValidator.validate(scenarioGraph, ProcessTestData.sampleProcessName, isFragment = false) + TestFactory.processValidator.validate( + scenarioGraph, + ProcessTestData.sampleProcessName, + isFragment = false, + labels = List.empty + ) } } @@ -2226,8 +2416,8 @@ private object UIProcessValidatorSpec { c.withValue(s"componentsUiConfig.$n.params.par1.defaultValue", fromAnyRef("'realDefault'")) ) - private val configuredValidator: UIProcessValidator = TestFactory.processValidator.withScenarioPropertiesConfig( - Map( + private val configuredValidator: UIProcessValidator = ProcessTestData.testProcessValidator( + scenarioProperties = Map( "requiredStringProperty" -> ScenarioPropertyConfig( defaultValue = None, editor = Some(StringParameterEditor), @@ -2255,9 +2445,10 @@ private object UIProcessValidatorSpec { def validateWithConfiguredProperties( scenario: ScenarioGraph, processName: ProcessName = ProcessTestData.sampleProcessName, - isFragment: Boolean = false + isFragment: Boolean = false, + labels: List[ScenarioLabel] = List.empty, ): ValidationResult = - configuredValidator.validate(scenario, processName, isFragment) + configuredValidator.validate(scenario, processName, isFragment, labels) val validFlinkScenarioGraph: ScenarioGraph = createGraph( List( @@ -2505,6 +2696,7 @@ private object UIProcessValidatorSpec { scenarioProperties = Map.empty, scenarioPropertiesConfigFinalizer = new ScenarioPropertiesConfigFinalizer(TestAdditionalUIConfigProvider, Streaming.stringify), + scenarioLabelsValidator = new ScenarioLabelsValidator(config = None), additionalValidators = List.empty, fragmentResolver = new FragmentResolver(new StubFragmentRepository(Map.empty)) ) @@ -2537,6 +2729,7 @@ private object UIProcessValidatorSpec { scenarioProperties = FlinkStreamingPropertiesConfig.properties, scenarioPropertiesConfigFinalizer = new ScenarioPropertiesConfigFinalizer(TestAdditionalUIConfigProvider, "Streaming"), + scenarioLabelsValidator = new ScenarioLabelsValidator(config = None), additionalValidators = List(SampleCustomProcessValidator), fragmentResolver = new FragmentResolver( new StubFragmentRepository( @@ -2562,8 +2755,8 @@ private object UIProcessValidatorSpec { override def apply(left: ValidationResult): MatchResult = { MatchResult( !left.hasErrors && !left.hasWarnings, - "ValidationResult should has neither errors nor warnings", - "ValidationResult should has either errors or warnings" + s"ValidationResult should has neither errors nor warnings but was: ${left}", + s"ValidationResult should has either errors or warnings but was:${left}" ) } diff --git a/designer/submodules/packages/components/src/common/index.ts b/designer/submodules/packages/components/src/common/index.ts index 97df9ed68ed..d875dced827 100644 --- a/designer/submodules/packages/components/src/common/index.ts +++ b/designer/submodules/packages/components/src/common/index.ts @@ -12,3 +12,4 @@ export type { NkViewProps } from "./nkView"; export { Highlight } from "./highlight"; export { UnavailableViewPlaceholder } from "./unavailableViewPlaceholder"; export { View } from "./view"; +export { Truncate, TruncateWrapper } from "./utils"; diff --git a/designer/submodules/packages/components/src/common/labelChip.tsx b/designer/submodules/packages/components/src/common/labelChip.tsx new file mode 100644 index 00000000000..1a581481312 --- /dev/null +++ b/designer/submodules/packages/components/src/common/labelChip.tsx @@ -0,0 +1,34 @@ +import React, { useCallback, useMemo } from "react"; +import { Chip } from "@mui/material"; + +interface Props { + key: string; + value: string; + filterValue: string[]; + setFilter: (value: string[]) => void; +} + +export function LabelChip({ key, value, filterValue, setFilter }: Props): JSX.Element { + const isSelected = useMemo(() => filterValue.includes(value), [filterValue, value]); + + const onClick = useCallback( + (e) => { + setFilter(isSelected ? filterValue.filter((v) => v !== value) : [...filterValue, value]); + e.preventDefault(); + e.stopPropagation(); + }, + [setFilter, isSelected, filterValue, value], + ); + + return ( + + ); +} diff --git a/designer/submodules/packages/components/src/components/utils/index.ts b/designer/submodules/packages/components/src/common/utils/index.ts similarity index 100% rename from designer/submodules/packages/components/src/components/utils/index.ts rename to designer/submodules/packages/components/src/common/utils/index.ts diff --git a/designer/submodules/packages/components/src/components/utils/truncate.tsx b/designer/submodules/packages/components/src/common/utils/truncate.tsx similarity index 100% rename from designer/submodules/packages/components/src/components/utils/truncate.tsx rename to designer/submodules/packages/components/src/common/utils/truncate.tsx diff --git a/designer/submodules/packages/components/src/components/utils/truncateWrapper.tsx b/designer/submodules/packages/components/src/common/utils/truncateWrapper.tsx similarity index 78% rename from designer/submodules/packages/components/src/components/utils/truncateWrapper.tsx rename to designer/submodules/packages/components/src/common/utils/truncateWrapper.tsx index 584be352c14..a6b727b855b 100644 --- a/designer/submodules/packages/components/src/components/utils/truncateWrapper.tsx +++ b/designer/submodules/packages/components/src/common/utils/truncateWrapper.tsx @@ -1,8 +1,8 @@ import { Visibility } from "@mui/icons-material"; -import { Box, Popover, PopoverOrigin, Stack, styled, Typography } from "@mui/material"; +import { Box, ClickAwayListener, Popover, PopoverOrigin, Stack, styled, Typography } from "@mui/material"; import { GridRenderCellParams } from "@mui/x-data-grid"; import { bindPopover, bindTrigger, PopupState, usePopupState } from "material-ui-popup-state/hooks"; -import React, { PropsWithChildren, useCallback, useRef } from "react"; +import React, { PropsWithChildren, ReactNode, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Truncate } from "./truncate"; @@ -56,10 +56,25 @@ const Truncator = ({ popupState: PopupState; }) => { const { t } = useTranslation(); + + const baseTrigger = bindTrigger(popupState); + const trigger = { + ...baseTrigger, + onClick: (e) => { + baseTrigger.onClick(e); + e.stopPropagation(); + e.preventDefault(); + }, + onTouchStart: (e) => { + baseTrigger.onTouchStart(e); + e.stopPropagation(); + e.preventDefault(); + }, + }; return ( - + - + {itemsCount === hiddenItemsCount ? t("truncator.allHidden", "{{hiddenItemsCount}} items...", { hiddenItemsCount }) : t("truncator.someHidden", "{{hiddenItemsCount}} more...", { hiddenItemsCount })} @@ -68,7 +83,7 @@ const Truncator = ({ ); }; -export function TruncateWrapper({ children }: PropsWithChildren): JSX.Element { +export function TruncateWrapper({ children }: PropsWithChildren>): JSX.Element { const popupState = usePopupState({ variant: "popover", popupId: "pop" }); const { anchorEl, ...popoverProps } = bindPopover(popupState); const ref = useRef(); @@ -101,6 +116,10 @@ export function TruncateWrapper({ children }: PropsWithChildren { + e.preventDefault(); + e.stopPropagation(); + }} open={popoverProps.open} anchorEl={ref.current || anchorEl} > diff --git a/designer/submodules/packages/components/src/components/cellRenderers/categoriesCell.tsx b/designer/submodules/packages/components/src/components/cellRenderers/categoriesCell.tsx index b05f6e25a23..df95ec798a5 100644 --- a/designer/submodules/packages/components/src/components/cellRenderers/categoriesCell.tsx +++ b/designer/submodules/packages/components/src/components/cellRenderers/categoriesCell.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { TruncateWrapper } from "../utils"; +import { TruncateWrapper } from "../../common/utils"; import { CategoryChip, useFilterContext } from "../../common"; import { ComponentsFiltersModel } from "../filters"; import { CellRendererParams } from "../tableWrapper"; @@ -10,7 +10,7 @@ export function CategoriesCell(props: CellRendererParams): JSX.Element { const filterValue = useMemo(() => getFilter("CATEGORY", true), [getFilter]); return ( - + {value.map((name) => ( ))} diff --git a/designer/submodules/packages/components/src/components/index.ts b/designer/submodules/packages/components/src/components/index.ts index c55ae49ee07..50651440d50 100644 --- a/designer/submodules/packages/components/src/components/index.ts +++ b/designer/submodules/packages/components/src/components/index.ts @@ -1,5 +1,4 @@ export * from "./cellRenderers"; export * from "./filters"; export * from "./usages"; -export * from "./utils"; export * from "./tableWrapper"; diff --git a/designer/submodules/packages/components/src/components/usages/nodesCell.tsx b/designer/submodules/packages/components/src/components/usages/nodesCell.tsx index a6b836d3a48..f5c27584648 100644 --- a/designer/submodules/packages/components/src/components/usages/nodesCell.tsx +++ b/designer/submodules/packages/components/src/components/usages/nodesCell.tsx @@ -2,7 +2,7 @@ import { createFilterRules, ExternalLink, fragmentNodeHref, Highlight, nodeHref, import React, { memo, useCallback, useMemo } from "react"; import { OpenInBrowser as LinkIcon } from "@mui/icons-material"; import { Chip, styled } from "@mui/material"; -import { TruncateWrapper } from "../utils"; +import { TruncateWrapper } from "../../common/utils"; import { GridRenderCellParams } from "@mui/x-data-grid"; import { NodeUsageData } from "nussknackerUi/HttpService"; import { UsageWithStatus } from "../useComponentsQuery"; @@ -72,7 +72,7 @@ export const NodesCell = ({ matched={filterText ? match : -1} /> )); - return {elements}; + return {elements}; }; const HighlightNode = styled(Highlight)({ diff --git a/designer/submodules/packages/components/src/scenarios/filters/filterRules.tsx b/designer/submodules/packages/components/src/scenarios/filters/filterRules.tsx index d914149383d..28e55392658 100644 --- a/designer/submodules/packages/components/src/scenarios/filters/filterRules.tsx +++ b/designer/submodules/packages/components/src/scenarios/filters/filterRules.tsx @@ -29,4 +29,5 @@ export const filterRules = createFilterRules({ !value?.length || [].concat(value).some((f) => row["createdBy"]?.includes(f) || row["modifiedBy"]?.includes(f)), STATUS: (row, value) => !value?.length || [].concat(value).some((f) => row["state"]?.status.name.includes(f)), PROCESSING_MODE: (row, value) => !value?.length || [].concat(value).some((f) => row["processingMode"].includes(f)), + LABEL: (row, value) => !value?.length || [].concat(value).some((f) => (row["labels"] ? row["labels"].includes(f) : false)), }); diff --git a/designer/submodules/packages/components/src/scenarios/filters/filtersPart.tsx b/designer/submodules/packages/components/src/scenarios/filters/filtersPart.tsx index 9de5f7b5808..6ddd0670b32 100644 --- a/designer/submodules/packages/components/src/scenarios/filters/filtersPart.tsx +++ b/designer/submodules/packages/components/src/scenarios/filters/filtersPart.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useMemo } from "react"; import { flatten, sortBy, uniq } from "lodash"; import { useFilterContext } from "../../common"; import { ScenariosFiltersModel, ScenariosFiltersModelType } from "./scenariosFiltersModel"; -import { useStatusDefinitions, useUserQuery } from "../useScenariosQuery"; +import { useScenarioLabelsQuery, useStatusDefinitions, useUserQuery } from "../useScenariosQuery"; import { QuickFilter } from "./quickFilter"; import { FilterMenu } from "./filterMenu"; import { SimpleOptionsStack } from "./simpleOptionsStack"; @@ -20,6 +20,7 @@ export function FiltersPart({ withSort, isLoading, data = [] }: { data: RowType[ const { t } = useTranslation(); const { data: userData } = useUserQuery(); const { data: statusDefinitions = [] } = useStatusDefinitions(); + const { data: availableLabels } = useScenarioLabelsQuery(); const filterableKeys = useMemo(() => ["createdBy", "modifiedBy"], []); const filterableValues = useMemo(() => { @@ -31,6 +32,7 @@ export function FiltersPart({ withSort, isLoading, data = [] }: { data: RowType[ .map((v) => ({ name: v })), status: sortBy(statusDefinitions, (v) => v.displayableName), processCategory: (userData?.categories || []).map((name) => ({ name })), + label: (availableLabels?.labels || []).map((name) => ({ name })), processingMode: processingModeItems, }; }, [data, filterableKeys, statusDefinitions, userData?.categories]); @@ -111,6 +113,17 @@ export function FiltersPart({ withSort, isLoading, data = [] }: { data: RowType[ })} /> + + + }): JSX.Element { const { setFilter, getFilter } = filtersContext; @@ -19,6 +21,14 @@ function Category({ value, filtersContext }: { value: string; filtersContext: Fi return ; } +function Labels({ values, filtersContext }: { values: string[]; filtersContext: FiltersContextType }): JSX.Element { + const { setFilter, getFilter } = filtersContext; + const filterValue = useMemo(() => getFilter("LABEL", true), [getFilter]); + + const elements = values.map((v) => ); + return {elements}; +} + export function LastAction({ lastAction }: { lastAction: ProcessActionType }): JSX.Element { const { t } = useTranslation(); @@ -52,7 +62,6 @@ export function SecondLine({ row }: { row: RowType }): JSX.Element { const { getFilter } = useFilterContext(); const [createdBy] = getFilter("CREATED_BY", true); const [sortedBy] = getFilter("SORT_BY", true); - const sortedByCreation = !sortedBy || sortedBy.startsWith("createdAt"); const filteredByCreation = createdBy === row.createdBy; const filtersContext = useFilterContext(); @@ -76,6 +85,7 @@ export function SecondLine({ row }: { row: RowType }): JSX.Element { {!row.isFragment && !row.isArchived && } + ); } diff --git a/designer/submodules/packages/components/src/scenarios/list/tablePart.tsx b/designer/submodules/packages/components/src/scenarios/list/tablePart.tsx index cd34e5c1116..cfefa903d46 100644 --- a/designer/submodules/packages/components/src/scenarios/list/tablePart.tsx +++ b/designer/submodules/packages/components/src/scenarios/list/tablePart.tsx @@ -62,6 +62,13 @@ export function TablePart(props: ListPartProps): JSX.Element { renderCell: (props) => filterKey="CREATED_BY" {...props} />, flex: 1, }, + { + field: "labels", + cellClassName: "stretch", + headerName: t("table.scenarios.title.LABEL", "Labels"), + renderCell: (props) => filterKey="LABEL" {...props} />, + flex: 1, + }, { field: "modificationDate", headerName: t("table.scenarios.title.MODIFICATION_DATE", "Modification date"), @@ -135,6 +142,7 @@ export function TablePart(props: ListPartProps): JSX.Element { (f === ScenariosFiltersModelType.FRAGMENTS && row.isFragment), ), CATEGORY: (row, value) => !value?.length || [].concat(value).some((f) => row["processCategory"] === f), + LABEL: (row, value) => !value?.length || [].concat(value).some((f) => row["labels"] === f), CREATED_BY: (row, value) => !value?.length || [].concat(value).some((f) => diff --git a/designer/submodules/packages/components/src/scenarios/useScenariosQuery.tsx b/designer/submodules/packages/components/src/scenarios/useScenariosQuery.tsx index edd566a26aa..822d5939dfd 100644 --- a/designer/submodules/packages/components/src/scenarios/useScenariosQuery.tsx +++ b/designer/submodules/packages/components/src/scenarios/useScenariosQuery.tsx @@ -4,6 +4,7 @@ import { NkApiContext } from "../settings/nkApiProvider"; import { Scenario, StatusDefinitionType } from "nussknackerUi/components/Process/types"; import { StatusesType } from "nussknackerUi/HttpService"; import { useQuery, useQueryClient } from "react-query"; +import { AvailableScenarioLabels } from "nussknackerUi/components/Labels/types"; import { UseQueryResult } from "react-query/types/react/types"; import { DateTime } from "luxon"; @@ -78,6 +79,19 @@ export function useUserQuery(): UseQueryResult { }); } +export function useScenarioLabelsQuery(): UseQueryResult { + const api = useContext(NkApiContext); + return useQuery({ + queryKey: ["scenarioLabels"], + queryFn: async () => { + const { data } = await api.fetchScenarioLabels(); + return data; + }, + enabled: !!api, + refetchInterval: false, + }); +} + export function useScenariosWithStatus(): UseQueryResult { const scenarios = useScenariosQuery(); const statuses = useScenariosStatusesQuery(); diff --git a/docs-internal/api/nu-designer-openapi.yaml b/docs-internal/api/nu-designer-openapi.yaml index 6821cd3bf28..898dd266f91 100644 --- a/docs-internal/api/nu-designer-openapi.yaml +++ b/docs-internal/api/nu-designer-openapi.yaml @@ -1173,39 +1173,6 @@ paths: application/json: schema: $ref: '#/components/schemas/MigrateScenarioRequestDto' - examples: - Example: - summary: Migrate given scenario to current Nu instance - value: - version: 1 - sourceEnvironmentId: testEnv - remoteUserName: testUser - processingMode: Unbounded-Stream - engineSetupName: Flink - processCategory: Category1 - scenarioGraph: - properties: - additionalFields: - properties: - parallelism: '' - metaDataType: LiteStreamMetaData - showDescription: false - nodes: - - id: source - ref: - typ: csv-source-lite - parameters: [] - type: Source - - id: sink - ref: - typ: dead-end-lite - parameters: [] - type: Sink - edges: - - from: source - to: sink - processName: test - isFragment: false required: true responses: '200': @@ -2249,6 +2216,193 @@ paths: security: - {} - httpAuth: [] + /api/scenarioLabels: + get: + tags: + - Scenario labels + summary: Service providing available scenario labels + operationId: getApiScenariolabels + parameters: + - name: Nu-Impersonate-User-Identity + in: header + required: false + schema: + type: + - string + - 'null' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ScenarioLabels' + examples: + Example: + summary: List of available scenario labels + value: + labels: + - Label_1 + - Label_2 + '400': + description: 'Invalid value for: header Nu-Impersonate-User-Identity' + content: + text/plain: + schema: + type: string + '401': + description: '' + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Authentication failed + value: The supplied authentication is invalid + '403': + description: '' + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Authorization failed + value: The supplied authentication is not authorized to access this + resource + '404': + description: Identity provided in the Nu-Impersonate-User-Identity header + did not match any user + content: + text/plain: + schema: + type: string + examples: + Example: + summary: No impersonated user's data found for provided identity + value: No impersonated user data found for provided identity + '501': + description: Impersonation is not supported for defined authentication mechanism + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Cannot authenticate impersonated user as impersonation + is not supported by the authentication mechanism + value: Provided authentication method does not support impersonation + security: + - {} + - httpAuth: [] + /api/scenarioLabels/validation: + post: + tags: + - Scenario labels + summary: Service providing scenario labels validation + operationId: postApiScenariolabelsValidation + parameters: + - name: Nu-Impersonate-User-Identity + in: header + required: false + schema: + type: + - string + - 'null' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ScenarioLabelsValidationRequestDto' + examples: + Example0: + summary: List of valid scenario labels + value: + labels: + - Label_1 + - Label_2 + Example1: + summary: List of scenario labels with invalid one + value: + labels: + - Label_1 + - Label_2 + - Label 3 + required: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ScenarioLabelsValidationResponseDto' + examples: + Example0: + summary: Validation response with no errors + value: + validationErrors: [] + Example1: + summary: Validation response with errors + value: + validationErrors: + - label: Label 3 + messages: + - Scenario label can contain only alphanumeric characters, '-' + and '_' + '400': + description: 'Invalid value for: header Nu-Impersonate-User-Identity, Invalid + value for: body' + content: + text/plain: + schema: + type: string + '401': + description: '' + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Authentication failed + value: The supplied authentication is invalid + '403': + description: '' + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Authorization failed + value: The supplied authentication is not authorized to access this + resource + '404': + description: Identity provided in the Nu-Impersonate-User-Identity header + did not match any user + content: + text/plain: + schema: + type: string + examples: + Example: + summary: No impersonated user's data found for provided identity + value: No impersonated user data found for provided identity + '501': + description: Impersonation is not supported for defined authentication mechanism + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Cannot authenticate impersonated user as impersonation + is not supported by the authentication mechanism + value: Provided authentication method does not support impersonation + security: + - {} + - httpAuth: [] /api/scenarioParametersCombinations: get: tags: @@ -4836,10 +4990,12 @@ components: title: MigrateScenarioRequestDto oneOf: - $ref: '#/components/schemas/MigrateScenarioRequestDtoV1' + - $ref: '#/components/schemas/MigrateScenarioRequestDtoV2' discriminator: propertyName: version mapping: '1': MigrateScenarioRequestDtoV1 + '2': MigrateScenarioRequestDtoV2 MigrateScenarioRequestDtoV1: title: MigrateScenarioRequestDtoV1 type: object @@ -4873,6 +5029,43 @@ components: type: string isFragment: type: boolean + MigrateScenarioRequestDtoV2: + title: MigrateScenarioRequestDtoV2 + type: object + required: + - version + - sourceEnvironmentId + - remoteUserName + - processingMode + - engineSetupName + - processCategory + - scenarioGraph + - processName + - isFragment + properties: + version: + type: integer + format: int32 + sourceEnvironmentId: + type: string + remoteUserName: + type: string + processingMode: + type: string + engineSetupName: + type: string + processCategory: + type: string + scenarioLabels: + type: array + items: + type: string + scenarioGraph: + type: object + processName: + type: string + isFragment: + type: boolean NextSwitch: title: NextSwitch type: object @@ -5973,6 +6166,30 @@ components: $ref: '#/components/schemas/ScenarioActivityComment' type: type: string + ScenarioLabels: + title: ScenarioLabels + type: object + properties: + labels: + type: array + items: + type: string + ScenarioLabelsValidationRequestDto: + title: ScenarioLabelsValidationRequestDto + type: object + properties: + labels: + type: array + items: + type: string + ScenarioLabelsValidationResponseDto: + title: ScenarioLabelsValidationResponseDto + type: object + properties: + validationErrors: + type: array + items: + $ref: '#/components/schemas/ValidationError' ScenarioModified: title: ScenarioModified type: object @@ -6570,6 +6787,18 @@ components: anyOf: - $ref: '#/components/schemas/LayoutData' - type: 'null' + ValidationError: + title: ValidationError + type: object + required: + - label + properties: + label: + type: string + messages: + type: array + items: + type: string ValueInputWithDictEditor: title: ValueInputWithDictEditor type: object diff --git a/docs/Changelog.md b/docs/Changelog.md index 773d43d03e3..34272098f12 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -49,6 +49,7 @@ - shorter message in logs * [#6886](https://github.com/TouK/nussknacker/pull/6886) Fix for "Illegal table name:$nuCatalog" error when using Apache Iceberg catalog. Internal Nussknacker catalog is now named `_nu_catalog` +* [#6766](https://github.com/TouK/nussknacker/pull/6766) Scenario labels support - you can assign labels to scenarios and use them to filter the scenario list ## 1.17 diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md index 50d8a170b02..13d87541ceb 100644 --- a/docs/MigrationGuide.md +++ b/docs/MigrationGuide.md @@ -10,6 +10,19 @@ To see the biggest differences please consult the [changelog](Changelog.md). * Added `typeHintsObjType` which is used as a type for a type hints, suggester and validation. * Renamed `objType` to `runtimeObjType` which indicates a current object in a runtime. +* [#6766](https://github.com/TouK/nussknacker/pull/6766) + * Process API changes: + * Field `ScenarioWithDetails.labels` was added + * Field `ScenarioWithDetails.tags` was removed (it had the same value as `labels` and was not used) + +### REST API changes + +* [#6766](https://github.com/TouK/nussknacker/pull/6766) + * Process API changes: + * PUT `/api/processes/{processName}` - optional `scenarioLabels` field added + * Migration API changes: + * POST `/api/migrate` supports v2 request format (with `scenarioLabels` field) + ### Other changes * [#6692](https://github.com/TouK/nussknacker/pull/6692) Kryo serializers for `UnmodifiableCollection`, `scala.Product` etc. diff --git a/docs/configuration/DesignerConfiguration.md b/docs/configuration/DesignerConfiguration.md index 12a58f4178d..16d19329502 100644 --- a/docs/configuration/DesignerConfiguration.md +++ b/docs/configuration/DesignerConfiguration.md @@ -744,20 +744,23 @@ You can configure `secondaryEnvironment` to allow for ## Other configuration options -| Parameter name | Importance | Type | Default value | Description | -|--------------------------------------------|------------|----------|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| attachments.maxSizeInBytes | Medium | long | 10485760 | Limits max size of scenario attachment, by default to 10mb | -| analytics.engine | Low | Matomo | | Currently only available analytics engine is [Matomo](https://matomo.org/) | -| analytics.url | Low | string | | URL of Matomo server | -| analytics.siteId | Low | string | | [Site id](https://matomo.org/faq/general/faq_19212/) | -| intervalTimeSettings.processes | Low | int | 20000 | How often frontend reloads scenario list | -| intervalTimeSettings.healthCheck | Low | int | 30000 | How often frontend reloads checks scenarios states | -| developmentMode | Medium | boolean | false | For development mode we disable some security features like CORS. **Don't** use in production | -| enableConfigEndpoint | Medium | boolean | false | Expose config over http (GET /api/app/config/) - requires admin permission. Please mind, that config often contains password or other confidential data - this feature is meant to be used only on 'non-prod' envrionments. | -| redirectAfterArchive | Low | boolean | true | Redirect to scenarios list after archive operation. | -| scenarioStateTimeout | Low | duration | 5 seconds | Timeout for fetching scenario state operation | -| usageStatisticsReports.enabled | Low | boolean | true | When enabled browser will send anonymous usage statistics reports to `stats.nussknacker.io` | -| usageStatisticsReports.errorReportsEnabled | Low | boolean | true | When enabled browser will send anonymous errors reports to `stats.nussknacker.io` | +| Parameter name | Importance | Type | Default value | Description | +|------------------------------------------------------------|------------|----------|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| attachments.maxSizeInBytes | Medium | long | 10485760 | Limits max size of scenario attachment, by default to 10mb | +| analytics.engine | Low | Matomo | | Currently only available analytics engine is [Matomo](https://matomo.org/) | +| analytics.url | Low | string | | URL of Matomo server | +| analytics.siteId | Low | string | | [Site id](https://matomo.org/faq/general/faq_19212/) | +| intervalTimeSettings.processes | Low | int | 20000 | How often frontend reloads scenario list | +| intervalTimeSettings.healthCheck | Low | int | 30000 | How often frontend reloads checks scenarios states | +| developmentMode | Medium | boolean | false | For development mode we disable some security features like CORS. **Don't** use in production | +| enableConfigEndpoint | Medium | boolean | false | Expose config over http (GET /api/app/config/) - requires admin permission. Please mind, that config often contains password or other confidential data - this feature is meant to be used only on 'non-prod' envrionments. | +| redirectAfterArchive | Low | boolean | true | Redirect to scenarios list after archive operation. | +| scenarioLabelSettings.validationRules | Low | array | [] | Allows to configure validation rules for scenario labels | +| scenarioLabelSettings.validationRules[0].validationPattern | Low | string | | [Regular expression](https://docs.oracle.com/javase/8/docs/api/java/util/regex/Pattern.html) for scenario label validation | +| scenarioLabelSettings.validationRules[0].validationMessage | Low | string | | Hint shown to the user when the scenario label does not match the given validation regex | +| scenarioStateTimeout | Low | duration | 5 seconds | Timeout for fetching scenario state operation | +| usageStatisticsReports.enabled | Low | boolean | true | When enabled browser will send anonymous usage statistics reports to `stats.nussknacker.io` | +| usageStatisticsReports.errorReportsEnabled | Low | boolean | true | When enabled browser will send anonymous errors reports to `stats.nussknacker.io` | ## Scenario type, categories diff --git a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/TransformStateTransformer.scala b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/TransformStateTransformer.scala index f1aae36f115..52bf05d960c 100644 --- a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/TransformStateTransformer.scala +++ b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/TransformStateTransformer.scala @@ -6,7 +6,12 @@ import org.apache.flink.util.Collector import pl.touk.nussknacker.engine.api._ import pl.touk.nussknacker.engine.api.context.{ContextTransformation, OutputVar} import pl.touk.nussknacker.engine.flink.api.compat.ExplicitUidInOperatorsSupport -import pl.touk.nussknacker.engine.flink.api.process.{FlinkCustomNodeContext, FlinkCustomStreamTransformation, FlinkLazyParameterFunctionHelper, LazyParameterInterpreterFunction} +import pl.touk.nussknacker.engine.flink.api.process.{ + FlinkCustomNodeContext, + FlinkCustomStreamTransformation, + FlinkLazyParameterFunctionHelper, + LazyParameterInterpreterFunction +} import pl.touk.nussknacker.engine.flink.api.state.LatelyEvictableStateFunction import pl.touk.nussknacker.engine.flink.util.richflink.FlinkKeyOperations diff --git a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EmitExtraWindowWhenNoDataTumblingAggregatorFunction.scala b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EmitExtraWindowWhenNoDataTumblingAggregatorFunction.scala index 7d465ac40d4..c6fd168e6df 100644 --- a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EmitExtraWindowWhenNoDataTumblingAggregatorFunction.scala +++ b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EmitExtraWindowWhenNoDataTumblingAggregatorFunction.scala @@ -9,7 +9,7 @@ import org.apache.flink.streaming.api.functions.KeyedProcessFunction import org.apache.flink.util.Collector import pl.touk.nussknacker.engine.api.runtimecontext.{ContextIdGenerator, EngineRuntimeContext} import pl.touk.nussknacker.engine.api.typed.typing.TypingResult -import pl.touk.nussknacker.engine.api.{NodeId, ValueWithContext, Context => NkContext} +import pl.touk.nussknacker.engine.api.{Context => NkContext, NodeId, ValueWithContext} import pl.touk.nussknacker.engine.flink.api.state.StateHolder import pl.touk.nussknacker.engine.flink.util.keyed.{KeyEnricher, StringKeyedValue} import pl.touk.nussknacker.engine.flink.util.orderedmap.FlinkRangeMap diff --git a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EmitWhenEventLeftAggregatorFunction.scala b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EmitWhenEventLeftAggregatorFunction.scala index dfce42cc8a1..8955783b8cb 100644 --- a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EmitWhenEventLeftAggregatorFunction.scala +++ b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EmitWhenEventLeftAggregatorFunction.scala @@ -8,7 +8,7 @@ import org.apache.flink.streaming.api.functions.KeyedProcessFunction import org.apache.flink.util.Collector import pl.touk.nussknacker.engine.api.runtimecontext.{ContextIdGenerator, EngineRuntimeContext} import pl.touk.nussknacker.engine.api.typed.typing.TypingResult -import pl.touk.nussknacker.engine.api.{NodeId, ValueWithContext, Context => NkContext} +import pl.touk.nussknacker.engine.api.{Context => NkContext, NodeId, ValueWithContext} import pl.touk.nussknacker.engine.flink.api.state.LatelyEvictableStateFunction import pl.touk.nussknacker.engine.flink.util.keyed.{KeyEnricher, StringKeyedValue} import pl.touk.nussknacker.engine.flink.util.orderedmap.FlinkRangeMap diff --git a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EnrichingWithKeyFunction.scala b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EnrichingWithKeyFunction.scala index f9762d53928..cdc45efbe7f 100644 --- a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EnrichingWithKeyFunction.scala +++ b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/EnrichingWithKeyFunction.scala @@ -6,7 +6,7 @@ import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction import org.apache.flink.streaming.api.windowing.windows.TimeWindow import org.apache.flink.util.Collector import pl.touk.nussknacker.engine.api.runtimecontext.{ContextIdGenerator, EngineRuntimeContext} -import pl.touk.nussknacker.engine.api.{ValueWithContext, Context => NkContext} +import pl.touk.nussknacker.engine.api.{Context => NkContext, ValueWithContext} import pl.touk.nussknacker.engine.flink.api.process.FlinkCustomNodeContext import pl.touk.nussknacker.engine.flink.util.keyed.KeyEnricher diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/compile/nodecompilation/NodeCompiler.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/compile/nodecompilation/NodeCompiler.scala index 872ad9f564b..dd07146afcd 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/compile/nodecompilation/NodeCompiler.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/compile/nodecompilation/NodeCompiler.scala @@ -267,7 +267,8 @@ class NodeCompiler( } def compileFragmentInput(fragmentInput: FragmentInput, ctx: ValidationContext)( - implicit nodeId: NodeId, metaData: MetaData + implicit nodeId: NodeId, + metaData: MetaData ): NodeCompilationResult[List[CompiledParameter]] = { val ref = fragmentInput.ref