diff --git a/workspace/src/app/store/ducks/common/typings.ts b/workspace/src/app/store/ducks/common/typings.ts index c20a8700b9..4ce2621ab7 100644 --- a/workspace/src/app/store/ducks/common/typings.ts +++ b/workspace/src/app/store/ducks/common/typings.ts @@ -105,6 +105,12 @@ export interface IPluginDetails { pluginType?: PluginType; markdownDocumentation?: string; autoConfigurable?: boolean; + relatedPlugins?: RelatedPlugin[]; +} + +export interface RelatedPlugin { + id: string; + description: string; } /** Overview version of an item description. */ diff --git a/workspace/src/app/views/shared/MultiTagSelect.tsx b/workspace/src/app/views/shared/MultiTagSelect.tsx index aa2f277ab8..2611fa1726 100644 --- a/workspace/src/app/views/shared/MultiTagSelect.tsx +++ b/workspace/src/app/views/shared/MultiTagSelect.tsx @@ -11,10 +11,12 @@ interface IProps { projectId?: string; handleTagSelectionChange: (params: MultiSuggestFieldSelectionProps) => any; initialTags?: Keyword[]; + selectedTags?: Keyword[]; + dataTestId?: string; } /** Multi selection component for project and task tags. */ -export const MultiTagSelect = ({ projectId, handleTagSelectionChange, initialTags }: IProps) => { +export const MultiTagSelect = ({ projectId, handleTagSelectionChange, initialTags, selectedTags, dataTestId }: IProps) => { const modalContext = React.useContext(CreateArtefactModalContext); const { registerError: globalErrorHandler } = useErrorHandler(); const registerError = modalContext.registerModalError ? modalContext.registerModalError : globalErrorHandler; @@ -41,11 +43,14 @@ export const MultiTagSelect = ({ projectId, handleTagSelectionChange, initialTag return ( - prePopulateWithItems={!!initialTags} + {...(selectedTags !== undefined + ? { selectedItems: selectedTags } + : { prePopulateWithItems: !!initialTags })} + data-test-id={dataTestId} openOnKeyDown itemId={(keyword) => keyword.uri} itemLabel={(keyword) => keyword.label} - items={initialTags ?? []} + items={selectedTags ?? initialTags ?? []} onSelection={handleTagSelectionChange} runOnQueryChange={handleTagQueryChange} newItemCreationText={t("Metadata.addNewTag")} diff --git a/workspace/src/app/views/shared/modals/CreateArtefactModal/ArtefactForms/ProjectForm.tsx b/workspace/src/app/views/shared/modals/CreateArtefactModal/ArtefactForms/ProjectForm.tsx index 7391c87aff..9388187894 100644 --- a/workspace/src/app/views/shared/modals/CreateArtefactModal/ArtefactForms/ProjectForm.tsx +++ b/workspace/src/app/views/shared/modals/CreateArtefactModal/ArtefactForms/ProjectForm.tsx @@ -70,8 +70,13 @@ export function ProjectForm({ form, goBackOnEscape = () => {}, updateProjectAcl }; const handleTagSelectionChange = React.useCallback( - (params: MultiSuggestFieldSelectionProps) => setValue("tags", params), - [], + (params: MultiSuggestFieldSelectionProps) => { + setValue(TAGS, params); + if ((params.newlySelected || params.newlyRemoved) && !escapeKeyDisabled.current) { + escapeKeyDisabled.current = true; + } + }, + [setValue], ); const CodeEditorMemoed = React.useMemo( diff --git a/workspace/src/app/views/shared/modals/CreateArtefactModal/ArtefactForms/ProjectSelection.tsx b/workspace/src/app/views/shared/modals/CreateArtefactModal/ArtefactForms/ProjectSelection.tsx index e3d5b84158..16afce0748 100644 --- a/workspace/src/app/views/shared/modals/CreateArtefactModal/ArtefactForms/ProjectSelection.tsx +++ b/workspace/src/app/views/shared/modals/CreateArtefactModal/ArtefactForms/ProjectSelection.tsx @@ -54,17 +54,14 @@ const ProjectSelection: React.FC = ({ isOpen={true} canEscapeKeyClose={true} onClose={onClose} - title={t("CreateModal.projectContext.resetModalTitle", "Project change warning")} + title={t("CreateModal.projectContext.resetModalTitle")} actions={[ - , + , + ]} + > + +

{t("CreateModal.relatedPlugins.switchWarningMessage")}

+
+ + ); +}; + +const pluginDetailsToPluginOverview = (pluginDetails: IPluginDetails): IPluginOverview => { + return { + key: pluginDetails.pluginId, + title: pluginDetails.title, + description: pluginDetails.description, + taskType: pluginDetails.taskType, + categories: pluginDetails.categories, + markdownDocumentation: pluginDetails.markdownDocumentation, + }; +}; + +const relatedPluginsToDocumentation = ( + relatedPlugins: RelatedPlugin[] | undefined, + allPluginOverviews: IPluginOverview[], + cachedArtefactProperties: Record, +): RelatedPluginDocumentation[] | undefined => { + if (!relatedPlugins?.length) { + return undefined; + } + + return relatedPlugins.map((relatedPlugin) => { + const overview = + allPluginOverviews.find( + (artefact) => artefact.pluginId === relatedPlugin.id || artefact.key === relatedPlugin.id, + ) ?? + (cachedArtefactProperties[relatedPlugin.id] + ? pluginDetailsToPluginOverview(cachedArtefactProperties[relatedPlugin.id]) + : { + key: relatedPlugin.id, + title: relatedPlugin.id, + description: relatedPlugin.description, + }); + + return { + plugin: overview, + description: relatedPlugin.description || overview.description || "", + }; + }); +}; + const pluginOverviewToArtefactDocumentation = ( pluginOverview: IPluginOverview, + allPluginOverviews: IPluginOverview[], + cachedArtefactProperties: Record, + pluginDetails?: IPluginDetails, namedAnchor?: string, ): ArtefactDocumentation => { + const resolvedPluginDetails = pluginDetails ?? cachedArtefactProperties[pluginOverview.key]; return { key: pluginOverview.key, - title: pluginOverview.title, - description: pluginOverview.description, - markdownDocumentation: pluginOverview.markdownDocumentation, + title: resolvedPluginDetails?.title ?? pluginOverview.title, + description: resolvedPluginDetails?.description ?? pluginOverview.description, + markdownDocumentation: resolvedPluginDetails?.markdownDocumentation ?? pluginOverview.markdownDocumentation, namedAnchor, + relatedPlugins: relatedPluginsToDocumentation( + resolvedPluginDetails?.relatedPlugins, + allPluginOverviews, + cachedArtefactProperties, + ), }; }; diff --git a/workspace/src/app/views/shared/modals/CreateArtefactModal/TaskDocumentationModal.tsx b/workspace/src/app/views/shared/modals/CreateArtefactModal/TaskDocumentationModal.tsx index 4a70270d89..ad141d0bc6 100644 --- a/workspace/src/app/views/shared/modals/CreateArtefactModal/TaskDocumentationModal.tsx +++ b/workspace/src/app/views/shared/modals/CreateArtefactModal/TaskDocumentationModal.tsx @@ -4,16 +4,25 @@ import { Button, HtmlContentBlock, Markdown, + OverviewItem, + OverviewItemActions, + OverviewItemDescription, + OverviewItemLine, + OverviewItemList, + Spacing, SimpleDialog, SimpleDialogProps, - CLASSPREFIX as eccgui, + CLASSPREFIX as eccgui, OverflowText, Tooltip, } from "@eccenca/gui-elements"; +import { useTranslation } from "react-i18next"; import { ArtefactDocumentation } from "./CreateArtefactModal"; +import { IPluginOverview } from "@ducks/common/typings"; interface TaskDocumentationModalProps { documentationToShow: ArtefactDocumentation; size?: SimpleDialogProps["size"]; onClose: () => any; + onSwitchToRelatedPlugin?: (plugin: IPluginOverview) => void; } const testId = "artefact-documentation-modal"; @@ -44,9 +53,11 @@ const findHeadingBefore = (element: Element): Element | undefined => { export const TaskDocumentationModal = ({ documentationToShow, onClose, + onSwitchToRelatedPlugin, size = "large", }: TaskDocumentationModalProps) => { const [initialized, setInitialized] = React.useState(false); + const [t] = useTranslation(); React.useEffect(() => { // If an anchor is defined, jump to it @@ -105,6 +116,51 @@ export const TaskDocumentationModal = ({ {documentationToShow.markdownDocumentation || documentationToShow.description || ""} + {documentationToShow.relatedPlugins && documentationToShow.relatedPlugins.length > 0 && ( + <> + +

{t("CreateModal.relatedPlugins.title")}

+ + {documentationToShow.relatedPlugins.map((relatedPlugin) => { + const pluginLabel = relatedPlugin.plugin.title ?? relatedPlugin.plugin.key; + return ( + + + + {pluginLabel} + + + + {relatedPlugin.description} + + + + {onSwitchToRelatedPlugin && ( + + + + )} + + ); + })} + + + )} ); diff --git a/workspace/src/locales/manual/en.json b/workspace/src/locales/manual/en.json index d446f154e5..426aaa0cb5 100644 --- a/workspace/src/locales/manual/en.json +++ b/workspace/src/locales/manual/en.json @@ -18,6 +18,13 @@ "changeProjectButton": "Change target project", "resetModalTitle": "Configuration reset necessary" }, + "relatedPlugins": { + "title": "Related plugins", + "switchAction": "Use {{pluginLabel}}", + "switchTooltip": "Loads the '{{pluginLabel}}' plugin into the form. Label, description, and tags are kept; all other settings are reset.", + "switchWarningTitle": "Switch plugin", + "switchWarningMessage": "There are unsaved changes in the form. Switching to the related plugin will reset all settings except label, description, and tags. Do you want to continue?" + }, "ReadOnlyParameter": { "label": "Read-only", "description": "If enabled, all write operations using this dataset object will fail, e.g. when used as output in workflows or transform/linking executions. This will NOT protect the underlying resource in general, e.g. files, databases or knowledge graphs could still be changed externally." diff --git a/workspace/test/integration/components/CreateArtefactModal/CreateArtefactModal.test.tsx b/workspace/test/integration/components/CreateArtefactModal/CreateArtefactModal.test.tsx index 4b6979af6a..07938c4469 100644 --- a/workspace/test/integration/components/CreateArtefactModal/CreateArtefactModal.test.tsx +++ b/workspace/test/integration/components/CreateArtefactModal/CreateArtefactModal.test.tsx @@ -13,18 +13,18 @@ import { checkRequestMade, cleanUpDOM, clickRenderedElement, - elementHtmlToContain, findAllDOMElements, findElement, legacyApiUrl, mockAxiosResponse, mockedAxiosError, mockedAxiosResponse, + pressKeyDown, RecursivePartial, renderWrapper, } from "../../TestHelper"; import { CreateArtefactModal } from "../../../../src/app/views/shared/modals/CreateArtefactModal/CreateArtefactModal"; -import { fireEvent, RenderResult, waitFor, screen, within } from "@testing-library/react"; +import { RenderResult, waitFor } from "@testing-library/react"; import { IOverviewArtefactItemList, IPluginDetails, @@ -35,7 +35,6 @@ import { INPUT_TYPES } from "../../../../src/app/constants"; import { TaskTypes } from "../../../../src/app/store/ducks/shared/typings"; import { MemoryHistory } from "history/createMemoryHistory"; import { bluePrintClassPrefix } from "../../../HierarchicalMapping/utils/TestHelpers"; - describe("Task creation widget", () => { beforeAll(() => { window.HTMLElement.prototype.scrollIntoView = function () {}; @@ -96,7 +95,10 @@ describe("Task creation widget", () => { }; const selectionItems = (dialogWrapper: RenderResult | Element): Element[] => { - return findAllDOMElements(dialogWrapper, ".eccgui-overviewitem__list .eccgui-overviewitem__item"); + return findAllDOMElements( + dialogWrapper, + `${byTestId("item-to-create-selection-list")} [data-test-id^="artefact-plugin-"]:not([data-test-id$="-documentation-btn"])`, + ); }; const pluginCreationDialogWrapper = async ( @@ -132,6 +134,85 @@ describe("Task creation widget", () => { return wrapper; }; + const respondWithPluginDetails = async (pluginId: string, pluginDetails: IPluginDetails) => { + await waitFor(() => { + mockAxiosResponse(apiUrl(`core/plugins/${pluginId}`), mockedAxiosResponse({ data: pluginDetails })); + }); + }; + + const expectFormParameterLabels = async (wrapper: RenderResult | Element, ...expectedLabels: string[]) => { + await waitFor(() => { + const labels = findAllDOMElements(wrapper, ".eccgui-label .eccgui-label__text").map((e) => e.textContent); + expectedLabels.forEach((label) => expect(labels).toContain(label)); + }); + }; + + const respondWithProjectTags = async ( + projectId: string, + filter: string, + tags: Array<{ uri: string; label: string }>, + ) => { + await waitFor(() => { + mockAxiosResponse( + apiUrl(`/workspace/projects/${projectId}/tags?filter=${filter}`), + mockedAxiosResponse({ data: { tags } }), + ); + }); + }; + + const expectTaskTags = async (wrapper: RenderResult | Element, ...expectedTags: string[]) => { + await waitFor(() => { + const tagSelection = findElement(wrapper, byTestId("task-tags-select")); + expectedTags.forEach((tag) => expect(tagSelection.textContent).toContain(tag)); + }); + }; + + const selectTaskTag = async ( + wrapper: RenderResult | Element, + tag: { + uri: string; + label: string; + }, + ) => { + const tagSelection = findElement(wrapper, byTestId("task-tags-select")); + const tagInput = findElement(tagSelection, "input") as HTMLInputElement; + + clickRenderedElement(tagInput); + await pressKeyDown(tagInput, tag.label[0]); + changeInputValue(tagInput, tag.label); + await respondWithProjectTags(PROJECT_ID, tag.label, [tag]); + + const tagOption = await waitFor(() => { + const listbox = findElement(document.body, '[role="listbox"]'); + const option = findAllDOMElements(listbox, ".eccgui-menu__item").find((item) => + item.textContent?.includes(tag.label), + ); + expect(option).toBeTruthy(); + return option as HTMLElement; + }); + clickRenderedElement(tagOption); + await expectTaskTags(wrapper, tag.label); + }; + + const openSelectionDocumentation = async ( + wrapper: RenderResult | Element, + pluginId: string, + pluginDetails: IPluginDetails, + ) => { + clickRenderedElement(findElement(wrapper, byTestId(`artefact-plugin-${pluginId}-documentation-btn`))); + await respondWithPluginDetails(pluginId, pluginDetails); + return await waitFor(() => findElement(wrapper, byTestId("artefact-documentation-modal"))); + }; + + const openSelectedPluginDocumentation = async (wrapper: RenderResult | Element) => { + clickRenderedElement(findElement(wrapper, byTestId("show-enhanced-description-btn"))); + return await waitFor(() => findElement(wrapper, byTestId("artefact-documentation-modal"))); + }; + + const useRelatedPlugin = (documentationModal: RenderResult | Element, pluginId: string) => { + clickRenderedElement(findElement(documentationModal, byTestId(`related-plugin-${pluginId}-use-btn`))); + }; + const mockArtefactListResponse: IOverviewArtefactItemList = { pluginA: { title: "Plugin A", @@ -155,6 +236,12 @@ describe("Task creation widget", () => { categories: ["category A", "category B"], required: ["intParam"], pluginId: "pluginA", + relatedPlugins: [ + { + id: "pluginB", + description: "Use Plugin B when you need dataset-specific handling.", + }, + ], properties: { intParam: atomicParamDescription({ title: "integer param", parameterType: INPUT_TYPES.INTEGER }), booleanParam: atomicParamDescription({ title: "boolean param", parameterType: INPUT_TYPES.BOOLEAN }), @@ -203,6 +290,27 @@ describe("Task creation widget", () => { }, }; + const mockPluginBDescription: IPluginDetails = { + title: "Plugin B", + description: "This is plugin B", + type: "object", + taskType: TaskTypes.DATASET, + categories: ["category C"], + required: ["bParam"], + pluginId: "pluginB", + properties: { + bParam: atomicParamDescription({ + title: "plugin B param", + parameterType: INPUT_TYPES.STRING, + }), + }, + }; + + const mockTag = { + uri: "tag1", + label: "Tag1", + }; + it("should show only the project artefact to select when on the main search page", async () => { const { element } = await createMockedListWrapper(); const dialog = await fetchDialog(element); @@ -474,21 +582,65 @@ describe("Task creation widget", () => { it("should check if the info Icon for task artefact exist", async () => { const { element } = await createMockedListWrapper(); - const dialog = await fetchDialog(element); - const items = selectionItems(dialog); - const randomItem = items[0]; - const iconButton = randomItem.querySelector(".eccgui-overviewitem__actions .eccgui-button--icon"); - expect(iconButton !== null).toBeTruthy(); + const iconButton = await waitFor(() => + findElement(element, byTestId("artefact-plugin-pluginA-documentation-btn")), + ); + expect(iconButton).toBeTruthy(); }); it("should show the info dialog when info icon is clicked", async () => { const { element } = await createMockedListWrapper(); - const dialog = await fetchDialog(element); - const items = selectionItems(dialog); - const randomItem = items[0]; - const iconButton = randomItem.querySelector(".eccgui-overviewitem__actions button.eccgui-button--icon"); - iconButton && fireEvent.click(iconButton); - const infoDialog = element.querySelector(".eccgui-card"); - expect(infoDialog !== null).toBeTruthy(); + const infoDialog = await openSelectionDocumentation(element, "pluginA", mockPluginDescription); + expect(infoDialog).toBeTruthy(); + expect(infoDialog.textContent).toContain("Related plugins"); + expect(infoDialog.textContent).toContain("Plugin B"); + expect(infoDialog.textContent).toContain("Use Plugin B when you need dataset-specific handling."); + }); + + it("should switch to a related plugin from the documentation modal", async () => { + const { element } = await createMockedListWrapper(); + const documentationModal = await openSelectionDocumentation(element, "pluginA", mockPluginDescription); + useRelatedPlugin(documentationModal, "pluginB"); + await respondWithPluginDetails("pluginB", mockPluginBDescription); + await expectFormParameterLabels(element, "plugin B param"); + }); + + it("should not show the related plugin action in update mode", async () => { + const { element } = await pluginCreationDialogWrapper(true, existingTask); + const documentationModal = await openSelectedPluginDocumentation(element); + expect(findAllDOMElements(documentationModal, byTestId("related-plugin-pluginB-use-btn"))).toHaveLength(0); + }); + + it("should keep tags when switching to a related plugin", async () => { + const { element } = await pluginCreationDialogWrapper(); + await selectTaskTag(element, mockTag); + const documentationModal = await openSelectedPluginDocumentation(element); + useRelatedPlugin(documentationModal, "pluginB"); + await respondWithPluginDetails("pluginB", mockPluginBDescription); + await expectFormParameterLabels(element, "plugin B param"); + await expectTaskTags(element, mockTag.label); + }); + + it("should ask for confirmation before switching to a related plugin when the form has changed", async () => { + const { element } = await pluginCreationDialogWrapper(); + changeInputValue(findElement(element, "#intParam") as HTMLInputElement, "100"); + const documentationModal = await openSelectedPluginDocumentation(element); + useRelatedPlugin(documentationModal, "pluginB"); + const warningModal = await waitFor(() => + findElement(element, byTestId("related-plugin-switch-warning-modal")), + ); + expect(warningModal.textContent).toContain("There are unsaved changes in the form."); + expect(mockAxios.getReqByUrl(apiUrl("/core/plugins/pluginB"))).toBeFalsy(); + + clickRenderedElement(findElement(element, byTestId("related-plugin-switch-cancel-btn"))); + await waitFor(() => { + expect(findAllDOMElements(element, byTestId("related-plugin-switch-warning-modal"))).toHaveLength(0); + }); + expect((findElement(element, "#intParam") as HTMLInputElement).value).toBe("100"); + + useRelatedPlugin(documentationModal, "pluginB"); + clickRenderedElement(findElement(element, byTestId("related-plugin-switch-confirm-btn"))); + await respondWithPluginDetails("pluginB", mockPluginBDescription); + await expectFormParameterLabels(element, "plugin B param"); }); });