diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index 0711382ae92..ee9d3646384 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -48,6 +48,7 @@ "/4vNBB": "Search logic apps...", "/5vL6M": "Run based on events in Azure services", "/88C7X": "At least 1 tool must be selected.", + "/BY2cI": "Select row", "/EU/oJ": "Required. A string that contains the time zone name of the source time zone. See 'Default Time Zones' at 'https://go.microsoft.com/fwlink/?linkid=2238292'.", "/IVuGP": "Discard", "/KRvvg": "No values match your search.", @@ -497,6 +498,7 @@ "8KpZmj": "The system-assigned identity is unavailable because it's not enabled.", "8L+oIz": "Cannot render designer due to multiple triggers in definition.", "8LhQeL": "Switch to code view mode", + "8M2YfK": "Delete", "8ND+Yc": "Please enable managed identity for the logic app.", "8NFfuB": "Actions", "8NUqpR": "Describe how your flow should be changed. Add details where possible, including the connector to use and if any content should be included.", @@ -512,6 +514,7 @@ "8eTWaf": "Rename", "8f/OBl": "Parameters", "8h1+4D": "An error occurred while validating the deployment. Details: {errorDetails}", + "8iLlRQ": "Successfully deleted the hub artifacts.", "8iX8Yu": "Create", "8j+a0n": "With the asynchronous pattern, if the remote server indicates that the request is accepted for processing with a 202 (Accepted) response, the Logic Apps engine will keep polling the URL specified in the response's location header until reaching a terminal state.", "8lZGy+": "Chat is only available in production when authentication is enabled on the app. This ensures secure access to your workflow.", @@ -564,6 +567,7 @@ "9bCLPz": "Loading API Management APIs...", "9bQctz": "Validation failed for workflows:", "9djnqI": "Returns the result from adding the two numbers", + "9euy52": "Complete", "9gb/xS": "Create", "9hKeBq": "Select an Azure OpenAI resource", "9klmbJ": "Save", @@ -709,6 +713,7 @@ "D5FIKL": "Paste from sample", "D6KzoS": "Specify upload chunk size between {minimumSize} and {maximumSize} Mb. Example: 10", "D89UXR": "action", + "DAqQK1": "Hubs: {hubNames}", "DDIIAQ": "Node", "DEu7oK": "(UTC-07:00) Arizona", "DGMwU4": "Use sample payload to generate schema", @@ -855,6 +860,7 @@ "GX3fkR": "New assertion", "GXFvm+": "To enable chat client for production, setup authentication.", "GYu5XE": "Failed to load run details", + "GYui13": "Continue editing", "GYvF54": "Referencing functions", "GZ8MDP": "{s1} of {s2}", "GbgqGL": "''{propertyName}'' is required", @@ -888,6 +894,7 @@ "H6IC6L": "Show Password", "H8bEUn": "Required. The number that Subtrahend is removed from.", "H9CZTr": "The expression is invalid. Make sure to use single quotes.", + "H9pzpO": "Deleted the following hub artifacts:", "HDqP2g": "Required. The key name of the form data value to return.", "HET2nV": "Add a Variable", "HF2SNx": "Is successful", @@ -897,11 +904,13 @@ "HILmmE": "Required. The collection to sort.", "HMJPEj": "Cannot add subsequent actions below agents in agent to agent workflows", "HMiE+4": "Exit full screen", + "HMyJSH": "Delete hub artifacts", "HOwcCC": "Primary key", "HQ/HhZ": "Error loading connections", "HQQtFz": "Make the field required", "HRXRwg": "Connectors provide actions for you to create tools. Select a connector and the actions you want. Finish by creating any needed connections.", "HSJLCu": "Headers", + "HTwVAR": "Deleting...", "HX3Xmx": "Deleting...", "HYhDYB": "(UTC-02:00) Coordinated Universal Time-02", "He4Z+v": "Credential type", @@ -930,6 +939,7 @@ "IHzSSN": "Path parameters", "ILcDyX": "Head on over to the gallery page to see your template in action.", "IMWSjN": "Loading resources ...", + "IOAsSh": "Agent", "IOQVnL": "Workflow display name is required for Save.", "IPwWgu": "(UTC+02:00) Jerusalem", "IQyOth": "If available, dynamic content is automatically generated from the connectors and actions you choose for your flow.", @@ -976,6 +986,7 @@ "JKQuh+": "Logic app name", "JKZpcd": "Copilot chat canceled", "JKfEGS": "Create new", + "JLWOQY": "List of knowledge hubs", "JNQHws": "Required. A string that contains the time.", "JQBEOg": "Review + create", "JRsTtp": "Task timeline", @@ -1063,6 +1074,7 @@ "Lnqh6h": "Bold (Ctrl+B)", "LoGUT3": "When used inside for-each loop, this function returns the current item of the specified loop.", "LpPNAD": "Add", + "Lsac0i": "Created", "LtbkS7": "Returns the start of the day for the passed-in string timestamp.", "Lu+3Y4": "Upload chunk size", "LuIkbo": "Expanding actions...", @@ -1313,6 +1325,7 @@ "QwAEWd": "Select a project", "QxEQwD": "Status", "R/aiRy": "(UTC+12:00) Coordinated Universal Time+12", + "R0Skk9": "Confirm that you want to delete this artifact? You can't undo this action.", "R7UxBX": "Learn more", "R7VvvJ": "Workflows", "RA4TUH": "Expand action", @@ -1475,11 +1488,13 @@ "UWF/WI": "View documentation", "UWsN6V": "Source schema node not found, or invalid custom value for key ''{nodeKey}''", "UXDOiw": "Description", + "UXbZTn": "Collapse hub", "UYRIS/": "{fileName} (file name)", "UZiXVh": "Output", "UcFx/i": "Add description", "UcVYwq": "Create new", "Ud5V1C": "Create new", + "Uf1R8k": "Description", "Ufv5m9": "Schema node ''{nodeName}'' has an non-terminating connection chain", "Ug4sWZ": "Expand", "UgaIRz": "Required. The length of each chunk.", @@ -1627,6 +1642,7 @@ "Xg1UDw": "Learn more", "Xj/wPS": "Agent chat", "Xj4xwI": "The managed identity used with this operation no longer exists. To continue, select an available identity or change the connection.", + "Xjhrkz": "Confirm that you want to delete this hub? This action also deletes all the hub's artifacts. You can't undo this action.", "XkBxv5": "Select a target schema", "Xkt2vD": "Select a function app function", "Xoz0Np": "Successfully updated the server.", @@ -1791,6 +1807,7 @@ "_/4vNBB.comment": "Placeholder text for logic app search", "_/5vL6M.comment": "Azure events trigger category description", "_/88C7X.comment": "Error message when no tools are selected in selected tools mode", + "_/BY2cI.comment": "Label for select row checkbox", "_/EU/oJ.comment": "Required string parameter for source time zone", "_/IVuGP.comment": "Button text for discard the dialog", "_/KRvvg.comment": "Label for when no values match search value.", @@ -2240,6 +2257,7 @@ "_8KpZmj.comment": "error message for unsupported system-assigned managed identity", "_8L+oIz.comment": "This is an error message shown when a user tries to load a workflow defintion that contains Multiple entry points which isn't supported", "_8LhQeL.comment": "Label for editor toggle button when in expanded mode", + "_8M2YfK.comment": "Label for the delete action", "_8ND+Yc.comment": "Error Message for disabled managed identity", "_8NFfuB.comment": "Title text for actions in the suggested flow", "_8NUqpR.comment": "Chatbot prompt to edit the workflow description", @@ -2255,6 +2273,7 @@ "_8eTWaf.comment": "Rename label", "_8f/OBl.comment": "The title for the action parameters section", "_8h1+4D.comment": "Error message shown when deployment validation fails", + "_8iLlRQ.comment": "Title for the toaster after successfully deleting hub artifacts", "_8iX8Yu.comment": "Button text for creating a new server", "_8j+a0n.comment": "description of asynchronous pattern setting", "_8lZGy+.comment": "Production section description in info dialog", @@ -2307,6 +2326,7 @@ "_9bCLPz.comment": "Loading API Management APIs...", "_9bQctz.comment": "The error title for the workflows tab", "_9djnqI.comment": "Label for description of custom add Function", + "_9euy52.comment": "Text to indicate that the artifact upload is completed", "_9gb/xS.comment": "Button text for creating a group", "_9hKeBq.comment": "Select the Azure Cognitive Service Open AI resource to use for this connection", "_9klmbJ.comment": "Button text for saving changes for parameter in the customize parameter panel", @@ -2452,6 +2472,7 @@ "_D5FIKL.comment": "Paste from sample", "_D6KzoS.comment": "tooltip for upload chunk size setting", "_D89UXR.comment": "Action text", + "_DAqQK1.comment": "The name of the hub to be deleted, shown in the delete confirmation modal", "_DDIIAQ.comment": "Title for other node", "_DEu7oK.comment": "Time zone value ", "_DGMwU4.comment": "Button Label for allowing users to generate from schema", @@ -2598,6 +2619,7 @@ "_GX3fkR.comment": "New Assertion Text", "_GXFvm+.comment": "Option 2 description when auth is not enabled", "_GYu5XE.comment": "Error message title when a single run fails to load", + "_GYui13.comment": "Button text for closing the delete hub artifacts modal", "_GYvF54.comment": "Label for referencing functions", "_GZ8MDP.comment": "Shows how many suggested flows there are", "_GbgqGL.comment": "Error message for missing property. Do not remove the double single quotes around the display name, as it is needed to wrap the placeholder text.", @@ -2631,6 +2653,7 @@ "_H6IC6L.comment": "Label to show password", "_H8bEUn.comment": "Required number parameter to be minused in sub function", "_H9CZTr.comment": "Invalid expression due to misused double quotes", + "_H9pzpO.comment": "Content for the toaster after successfully deleting hub artifacts, with the names of the deleted artifacts", "_HDqP2g.comment": "Required string parameter to be used as key for triggerFormDataValue function", "_HET2nV.comment": "label to add a variable", "_HF2SNx.comment": "Successful action result", @@ -2640,11 +2663,13 @@ "_HILmmE.comment": "Required collection parameter to apply sort function on", "_HMJPEj.comment": "Message shown when action addition is disabled within agents in A2A workflows", "_HMiE+4.comment": "Token picker for 'Exit full screen'", + "_HMyJSH.comment": "Title for the delete hub artifacts modal", "_HOwcCC.comment": "Text for primary access key", "_HQ/HhZ.comment": "Title for error message when loading connections", "_HQQtFz.comment": "Make the dynamic parameter corresponding to this row required", "_HRXRwg.comment": "Description for the connectors section", "_HSJLCu.comment": "The title of the headers field in the static result http action", + "_HTwVAR.comment": "Button text for when the hub artifacts are being deleted", "_HX3Xmx.comment": "Text for loading state of delete modal", "_HYhDYB.comment": "Time zone value ", "_He4Z+v.comment": "Authentication OAuth Type Label", @@ -2673,6 +2698,7 @@ "_IHzSSN.comment": "Display name for relative path parameters in trigger outputs", "_ILcDyX.comment": "Content for the toaster for after publishing template.", "_IMWSjN.comment": "Loading text", + "_IOAsSh.comment": "Label for the agent column", "_IOQVnL.comment": "Hint message for workflow display name is required for save.", "_IPwWgu.comment": "Time zone value ", "_IQyOth.comment": "Section 1 of text for including dynamic content section", @@ -2719,6 +2745,7 @@ "_JKQuh+.comment": "Label for the logic app name field", "_JKZpcd.comment": "Chatbot card telling user that the AI response is being canceled", "_JKfEGS.comment": "Button to add a new connection", + "_JLWOQY.comment": "The aria label for the knowledge hubs table", "_JNQHws.comment": "Required string parameter that contains the time", "_JQBEOg.comment": "The tab label for the monitoring review and create tab on the create workflow panel", "_JRsTtp.comment": "Title for the monitoring timeline component.", @@ -2806,6 +2833,7 @@ "_Lnqh6h.comment": "Command for bold text for non-mac users", "_LoGUT3.comment": "Label for description of custom item Function", "_LpPNAD.comment": "label to add a condition", + "_Lsac0i.comment": "Label for the created date column", "_LtbkS7.comment": "Label for the description of a custom 'startOfDay' function", "_Lu+3Y4.comment": "label for upload chunk size", "_LuIkbo.comment": "This is the text that is displayed when the user is expanding collapsed actions", @@ -3056,6 +3084,7 @@ "_QwAEWd.comment": "Select the project to use for this connection", "_QxEQwD.comment": "Status filter label", "_R/aiRy.comment": "Time zone value ", + "_R0Skk9.comment": "Content for the delete artifact", "_R7UxBX.comment": "Link text for learning more about knowledge base group", "_R7VvvJ.comment": "The tab label for the monitoring workflows tab on the configure template wizard", "_RA4TUH.comment": "Text indicating a menu button to expand an action in the designer", @@ -3218,11 +3247,13 @@ "_UWF/WI.comment": "Text for view docs button", "_UWsN6V.comment": "Error message for source schema node not found", "_UXDOiw.comment": "Parameter Field Description Title", + "_UXbZTn.comment": "Aria label for collapse hub button", "_UYRIS/.comment": "Title for file name parameter", "_UZiXVh.comment": "The title of the output field in the static result http action", "_UcFx/i.comment": "Title for the trigger description dialog.", "_UcVYwq.comment": "Label for creating a new resource in the token field.", "_Ud5V1C.comment": "Button label for create agent parameter", + "_Uf1R8k.comment": "Label for the description column", "_Ufv5m9.comment": "Body text for an unconnected required schema card", "_Ug4sWZ.comment": "Expand to make the node bigger and show the contents.", "_UgaIRz.comment": "Required number parameter to get length of each chunk for chunk function", @@ -3370,6 +3401,7 @@ "_Xg1UDw.comment": "Link to learn more about state type", "_Xj/wPS.comment": "Agent chat title", "_Xj4xwI.comment": "Erorr mesade when managed identity is not present in logic apps", + "_Xjhrkz.comment": "Content for the delete hub", "_XkBxv5.comment": "Target schema dropdown placeholder", "_Xkt2vD.comment": "Label for function app selection", "_Xoz0Np.comment": "Title for the toaster after updating a server", @@ -3743,9 +3775,11 @@ "_fg/34o.comment": "Label for logical functions", "_fifSPb.comment": "Time zone value ", "_flNr70.comment": "Title for the connection details section in basics tab for quick app create panel", + "_fm59Od.comment": "The name of the artifact to be deleted, shown in the delete confirmation modal", "_fmm7Ik.comment": "Token picker mode to insert expressions", "_focYsW.comment": "Button text for canceling inline Foundry agent creation", "_fp8Ry3.comment": "Time zone value ", + "_fs92Nu.comment": "Text to indicate that the artifact upload has failed", "_fsRie2.comment": "Description for workflow summary field", "_ft8BH8.comment": "Seconds", "_fvGvnA.comment": "Chatbot error message", @@ -3799,6 +3833,7 @@ "_gvo1S7.comment": "Warning message when agent is disconnected from the flow", "_gwEKLM.comment": "This is a message shown while loading. This announced text is read aloud with screen readers. Not shown in text.", "_gxqCx0.comment": "Button text for closing creation for MCP server", + "_gyfZhJ.comment": "Text to indicate that the artifact upload is in progress", "_h+W3VW.comment": "Label for Number type dynamically added parameter", "_h+ZYip.comment": "Option to install a new gateway, links to new page", "_h0ATm8.comment": "Placeholder text while loading groups in add files panel", @@ -3839,6 +3874,7 @@ "_hdN+aw.comment": "Description for host field", "_hesDPs.comment": "Milliseconds", "_hflWi6.comment": "Description for trimByteOrderMark function", + "_hfz4Il.comment": "Aria label for expand hub button", "_hh3i/V.comment": "Other trigger methods category description", "_hhW/w8.comment": "Recurrence additional message if no minutes or starttime is specified", "_hj/ald.comment": "Button text for adding an agent", @@ -4080,6 +4116,7 @@ "_meVkB6.comment": "Empty property name error message", "_mej02C.comment": "Time zone value ", "_merl0X.comment": "Placeholder for model dropdown", + "_mfpHrs.comment": "Label for the upload artifacts action", "_mgD2ZT.comment": "The tab label for the monitoring parameters tab on the operation panel", "_mjS/k1.comment": "description of suppress woers setting", "_mlU+AC.comment": "Header for the connections panel", @@ -4465,6 +4502,7 @@ "_uWLpFG.comment": "Evaluation trigger category", "_uWf/I5.comment": "Button to open map checker", "_uXecuj.comment": "Text to show missing required fields in the template.", + "_uYxnwQ.comment": "Content for the delete hub artifacts modal", "_uZpzqY.comment": "Chatbot report a bug button", "_uc/PoD.comment": "Required integer parameter to see how far in the past", "_uczA5c.comment": "Required text parameter to search startsWith function on", @@ -4549,6 +4587,7 @@ "_w/tTbg.comment": "Text displayed while loading knowledge hubs", "_w0pNyJ.comment": "Label text for agent key", "_w16qh+.comment": "Display name for queries in outputs", + "_w1QL1r.comment": "Button text for deleting the hub artifacts", "_w2VjJS.comment": "Aria label for closing the MCP server creation panel", "_w2rxzD.comment": "Text to explain that there are no executed tools in the agent iteration", "_w3BZ0u.comment": "Placeholder text for connector search input", @@ -4632,6 +4671,7 @@ "_xYyPR8.comment": "Label for description of custom uriPath Function", "_xd5jz/.comment": "Warning title for when unable to parse schema", "_xfIp1j.comment": "Cancelled status", + "_xfUoo5.comment": "Label for the status column", "_xfXUGz.comment": "Minute", "_xgV4pp.comment": "Text for the \"Select All\" option in a multiselect dropdown", "_xhBvXj.comment": "Button text for opening test panel", @@ -4980,9 +5020,11 @@ "fg/34o": "Logical functions", "fifSPb": "(UTC-03:30) Newfoundland", "flNr70": "Details", + "fm59Od": "Artifacts: {artifactNames}", "fmm7Ik": "Function", "focYsW": "Cancel", "fp8Ry3": "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi", + "fs92Nu": "Error", "fsRie2": "A short overview of what the template does.", "ft8BH8": "{count} Seconds", "fvGvnA": "Sorry, something went wrong. Please try again.", @@ -5036,6 +5078,7 @@ "gvo1S7": "Agent is unreachable in flow structure", "gwEKLM": "Loading...", "gxqCx0": "Close", + "gyfZhJ": "In progress", "h+W3VW": "Number", "h+ZYip": "{addIcon} Install gateway", "h0ATm8": "Loading groups...", @@ -5076,6 +5119,7 @@ "hdN+aw": "Specifies which Logic App sku the template supports (e.g., Standard, Consumption).", "hesDPs": "{count} Milliseconds", "hflWi6": "Removes Byte Order Mark (BOM) characters from the beginning of strings or binary content.", + "hfz4Il": "Expand hub", "hh3i/V": "Browse all available triggers", "hhW/w8": "If a recurrence doesn't specify a specific start date and time, the first recurrence runs immediately when you save or deploy the logic app", "hj/ald": "Add an agent", @@ -5317,6 +5361,7 @@ "meVkB6": "Empty property name", "mej02C": "(UTC+08:30) Pyongyang", "merl0X": "Select a model", + "mfpHrs": "Upload artifacts", "mgD2ZT": "Summary", "mjS/k1": "Limit Logic Apps to not include workflow metadata headers in the outgoing request.", "mlU+AC": "Connections", @@ -5702,6 +5747,7 @@ "uWLpFG": "When running evaluation", "uWf/I5": "Show me", "uXecuj": "Missing required fields:", + "uYxnwQ": "Confirm that you want to delete these hub artifacts? Deleting a hub deletes all its artifacts. You can't undo this action.", "uZpzqY": "Sorry, Copilot is at capacity and temporarily unavailable — please try again in a little while.", "uc/PoD": "Required. The number of time units the desired time is in the past.", "uczA5c": "Required. The string that may contain the value.", @@ -5786,6 +5832,7 @@ "w/tTbg": "Loading...", "w0pNyJ": "Agent Key:", "w16qh+": "Queries", + "w1QL1r": "Delete", "w2VjJS": "Close MCP server creation panel", "w2rxzD": "This iteration has completed without any tool execution", "w3BZ0u": "Search...", @@ -5869,6 +5916,7 @@ "xYyPR8": "Returns the path from a URI. If path is not specified, returns '/'", "xd5jz/": "Can't parse the schema for the agent parameter.", "xfIp1j": "Cancelled", + "xfUoo5": "Upload status", "xfXUGz": "{count} Minute", "xgV4pp": "Select all", "xhBvXj": "Open test panel", diff --git a/libs/designer/src/lib/core/knowledge/utils/__test__/helper.spec.ts b/libs/designer/src/lib/core/knowledge/utils/__test__/helper.spec.ts index 4b5b83aeb34..72d42e6c99c 100644 --- a/libs/designer/src/lib/core/knowledge/utils/__test__/helper.spec.ts +++ b/libs/designer/src/lib/core/knowledge/utils/__test__/helper.spec.ts @@ -1,4 +1,4 @@ -import { createKnowledgeHub } from '../helper'; +import { createKnowledgeHub, deleteKnowledgeHubArtifacts } from '../helper'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockExecuteResourceAction = vi.fn(); @@ -40,10 +40,10 @@ describe('knowledge helper utils', () => { expect(mockExecuteResourceAction).toHaveBeenCalledTimes(1); expect(mockExecuteResourceAction).toHaveBeenCalledWith( - `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHub/${groupName}`, + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/${groupName}`, 'PUT', { - 'api-version': '2025-11-01', + 'api-version': '2018-11-01', 'Content-Type': 'application/json', }, JSON.stringify({ description }) @@ -128,4 +128,95 @@ describe('knowledge helper utils', () => { }); }); }); + + describe('deleteKnowledgeHubArtifacts', () => { + it('should call ResourceService to delete each hub', async () => { + mockExecuteResourceAction.mockResolvedValue({}); + const hubs = ['hub1', 'hub2']; + const artifacts = {}; + + await deleteKnowledgeHubArtifacts(siteResourceId, hubs, artifacts); + + expect(mockExecuteResourceAction).toHaveBeenCalledTimes(2); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hub1`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hub2`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + }); + + it('should call ResourceService to delete each artifact', async () => { + mockExecuteResourceAction.mockResolvedValue({}); + const hubs: string[] = []; + const artifacts = { artifact1: 'hubA', artifact2: 'hubB' }; + + await deleteKnowledgeHubArtifacts(siteResourceId, hubs, artifacts); + + expect(mockExecuteResourceAction).toHaveBeenCalledTimes(2); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hubA/artifacts/artifact1`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hubB/artifacts/artifact2`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + }); + + it('should delete both hubs and artifacts when both are provided', async () => { + mockExecuteResourceAction.mockResolvedValue({}); + const hubs = ['hubToDelete']; + const artifacts = { 'my-artifact': 'hubWithArtifact' }; + + await deleteKnowledgeHubArtifacts(siteResourceId, hubs, artifacts); + + expect(mockExecuteResourceAction).toHaveBeenCalledTimes(2); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hubToDelete`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hubWithArtifact/artifacts/my-artifact`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + }); + + it('should return empty array when no hubs or artifacts are provided', async () => { + const result = await deleteKnowledgeHubArtifacts(siteResourceId, [], {}); + + expect(mockExecuteResourceAction).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('should return all resolved promises', async () => { + const response1 = { deleted: 'hub1' }; + const response2 = { deleted: 'artifact1' }; + mockExecuteResourceAction.mockResolvedValueOnce(response1).mockResolvedValueOnce(response2); + + const hubs = ['hub1']; + const artifacts = { artifact1: 'hubA' }; + + const result = await deleteKnowledgeHubArtifacts(siteResourceId, hubs, artifacts); + + expect(result).toEqual([response1, response2]); + }); + + it('should reject when any delete operation fails', async () => { + const error = new Error('Delete failed'); + mockExecuteResourceAction.mockResolvedValueOnce({}).mockRejectedValueOnce(error); + + const hubs = ['hub1', 'hub2']; + + await expect(deleteKnowledgeHubArtifacts(siteResourceId, hubs, {})).rejects.toThrow('Delete failed'); + }); + }); }); diff --git a/libs/designer/src/lib/core/knowledge/utils/__test__/queries.spec.ts b/libs/designer/src/lib/core/knowledge/utils/__test__/queries.spec.ts index 78cc95a8880..1735c734d2e 100644 --- a/libs/designer/src/lib/core/knowledge/utils/__test__/queries.spec.ts +++ b/libs/designer/src/lib/core/knowledge/utils/__test__/queries.spec.ts @@ -4,7 +4,7 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useAllKnowledgeHubs, getArtifactsInHub, useConnection, getCosmosDbEndpoint } from '../queries'; +import { useAllKnowledgeHubs, useConnection, getCosmosDbEndpoint } from '../queries'; import React from 'react'; const mockExecuteResourceAction = vi.fn(); @@ -62,16 +62,8 @@ describe('knowledge queries', () => { { name: 'hub-a', description: 'First hub' }, ]; - const mockArtifacts = [ - { name: 'artifact-1', type: 'document' }, - { name: 'artifact-2', type: 'document' }, - ]; - - test('should fetch and sort knowledge hubs with their artifacts', async () => { - mockExecuteResourceAction - .mockResolvedValueOnce({ value: mockHubs }) - .mockResolvedValueOnce({ value: mockArtifacts }) - .mockResolvedValueOnce({ value: [] }); + test('should fetch and sort knowledge hubs alphabetically', async () => { + mockGetResource.mockResolvedValueOnce(mockHubs); const { result } = renderHook(() => useAllKnowledgeHubs(siteResourceId), { wrapper: createWrapper, @@ -81,11 +73,9 @@ describe('knowledge queries', () => { expect(result.current.isSuccess).toBe(true); }); - expect(mockExecuteResourceAction).toHaveBeenCalledWith( - `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHub`, - 'GET', - { 'api-version': '2025-11-01' } - ); + expect(mockGetResource).toHaveBeenCalledWith(`${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgehubs`, { + 'api-version': '2018-11-01', + }); // Hubs should be sorted alphabetically expect(result.current.data?.[0].name).toBe('hub-a'); @@ -95,7 +85,7 @@ describe('knowledge queries', () => { test('should return empty array and log error on failure', async () => { const error = { code: 'NotFound', message: 'Resource not found' }; - mockExecuteResourceAction.mockRejectedValue({ error }); + mockGetResource.mockRejectedValue({ error }); const { result } = renderHook(() => useAllKnowledgeHubs(siteResourceId), { wrapper: createWrapper, @@ -121,11 +111,11 @@ describe('knowledge queries', () => { // Query should not run expect(result.current.fetchStatus).toBe('idle'); - expect(mockExecuteResourceAction).not.toHaveBeenCalled(); + expect(mockGetResource).not.toHaveBeenCalled(); }); test('should handle empty hubs response', async () => { - mockExecuteResourceAction.mockResolvedValueOnce({ value: [] }); + mockGetResource.mockResolvedValueOnce([]); const { result } = renderHook(() => useAllKnowledgeHubs(siteResourceId), { wrapper: createWrapper, @@ -137,62 +127,19 @@ describe('knowledge queries', () => { expect(result.current.data).toEqual([]); }); - }); - - describe('getArtifactsInHub', () => { - test('should fetch and sort artifacts for a hub', async () => { - const hubName = 'test-hub-sort'; - const mockArtifacts = [ - { name: 'doc-z', type: 'document' }, - { name: 'doc-a', type: 'document' }, - ]; - mockExecuteResourceAction.mockResolvedValue({ value: mockArtifacts }); - - const result = await getArtifactsInHub(siteResourceId, hubName); - - expect(mockExecuteResourceAction).toHaveBeenCalledWith( - `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHub/${hubName}/knowledgeArtifact`, - 'GET', - { 'api-version': '2025-11-01' } - ); - - // Artifacts should be sorted alphabetically - expect(result[0].name).toBe('doc-a'); - expect(result[1].name).toBe('doc-z'); - }); - test('should return empty array and log error on failure', async () => { - const hubName = 'test-hub-error'; - const error = { code: 'BadRequest', message: 'Invalid hub' }; - mockExecuteResourceAction.mockRejectedValue({ error }); - - const result = await getArtifactsInHub(siteResourceId, hubName); + test('should handle null response', async () => { + mockGetResource.mockResolvedValueOnce(null); - expect(result).toEqual([]); - expect(mockLog).toHaveBeenCalledWith({ - level: 'Error', - area: 'KnowledgeHub.listKnowledgeHubArtifacts', - error, - message: `Error while fetching knowledge artifacts for the app: ${siteResourceId}`, + const { result } = renderHook(() => useAllKnowledgeHubs(siteResourceId), { + wrapper: createWrapper, }); - }); - - test('should handle empty artifacts response', async () => { - const hubName = 'test-hub-empty'; - mockExecuteResourceAction.mockResolvedValue({ value: [] }); - - const result = await getArtifactsInHub(siteResourceId, hubName); - expect(result).toEqual([]); - }); - - test('should handle response without value property', async () => { - const hubName = 'test-hub-no-value'; - mockExecuteResourceAction.mockResolvedValue({}); - - const result = await getArtifactsInHub(siteResourceId, hubName); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); - expect(result).toEqual([]); + expect(result.current.data).toEqual([]); }); }); diff --git a/libs/designer/src/lib/core/knowledge/utils/helper.ts b/libs/designer/src/lib/core/knowledge/utils/helper.ts index b12bebd0f54..49a2ea7a505 100644 --- a/libs/designer/src/lib/core/knowledge/utils/helper.ts +++ b/libs/designer/src/lib/core/knowledge/utils/helper.ts @@ -4,10 +4,10 @@ import { getReactQueryClient } from '../../ReactQueryProvider'; export const createKnowledgeHub = async (siteResourceId: string, groupName: string, description: string) => { try { const response = await ResourceService().executeResourceAction( - `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHub/${groupName}`, + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/${groupName}`, 'PUT', { - 'api-version': '2025-11-01', + 'api-version': '2018-11-01', 'Content-Type': 'application/json', }, JSON.stringify({ description }) @@ -30,3 +30,30 @@ export const createKnowledgeHub = async (siteResourceId: string, groupName: stri }); } }; + +export const deleteKnowledgeHubArtifacts = async (siteResourceId: string, hubs: string[], artifacts: Record) => { + const promises: Promise[] = []; + + for (const hubName of hubs) { + promises.push( + ResourceService().executeResourceAction( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/${hubName}`, + 'DELETE', + { 'api-version': '2018-11-01' } + ) + ); + } + + for (const artifactName of Object.keys(artifacts)) { + const hubName = artifacts[artifactName]; + promises.push( + ResourceService().executeResourceAction( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/${hubName}/artifacts/${artifactName}`, + 'DELETE', + { 'api-version': '2018-11-01' } + ) + ); + } + + return Promise.all(promises); +}; diff --git a/libs/designer/src/lib/core/knowledge/utils/queries.ts b/libs/designer/src/lib/core/knowledge/utils/queries.ts index 0549391d300..fa75118107b 100644 --- a/libs/designer/src/lib/core/knowledge/utils/queries.ts +++ b/libs/designer/src/lib/core/knowledge/utils/queries.ts @@ -3,7 +3,6 @@ import { ConnectionService, equals, type KnowledgeHub, - type KnowledgeHubArtifact, type KnowledgeHubExtended, LogEntryLevel, LoggerService, @@ -24,21 +23,14 @@ export const useAllKnowledgeHubs = (siteResourceId: string) => { queryKey: ['knowledgehubs', siteResourceId.toLowerCase()], queryFn: async (): Promise => { try { - const response: any = await ResourceService().executeResourceAction( - `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHub`, - 'GET', - { 'api-version': '2025-11-01' } + const response: any = await ResourceService().getResource( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgehubs`, + { 'api-version': '2018-11-01' } ); - const hubs = (response.value ?? []).sort((a: KnowledgeHub, b: KnowledgeHub) => a.name.localeCompare(b.name)); + const hubs = (response ?? []).sort((a: KnowledgeHub, b: KnowledgeHub) => a.name.localeCompare(b.name)); - const promises: Promise[] = hubs.map((hub: KnowledgeHub) => getArtifactsInHub(siteResourceId, hub.name)); - - const extendedHubs = await Promise.all(promises); - return hubs.map((hub: KnowledgeHub, index: number) => ({ - ...hub, - artifacts: extendedHubs[index], - })); + return hubs; } catch (errorResponse: any) { const error = errorResponse?.error || {}; @@ -57,35 +49,6 @@ export const useAllKnowledgeHubs = (siteResourceId: string) => { }); }; -export const getArtifactsInHub = async (siteResourceId: string, hubName: string) => { - const queryClient = getReactQueryClient(); - - return queryClient.fetchQuery( - ['knowledgeartifacts', siteResourceId.toLowerCase(), hubName.toLowerCase()], - async (): Promise => { - try { - const response: any = await ResourceService().executeResourceAction( - `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHub/${hubName}/knowledgeArtifact`, - 'GET', - { 'api-version': '2025-11-01' } - ); - - return (response.value ?? []).sort((a: KnowledgeHubArtifact, b: KnowledgeHubArtifact) => a.name.localeCompare(b.name)); - } catch (errorResponse: any) { - const error = errorResponse?.error || {}; - // For now log the error and return empty list - LoggerService().log({ - level: LogEntryLevel.Error, - area: 'KnowledgeHub.listKnowledgeHubArtifacts', - error, - message: `Error while fetching knowledge artifacts for the app: ${siteResourceId}`, - }); - return []; - } - } - ); -}; - export const useConnection = () => { return useQuery({ queryKey: ['knowledgeconnection'], diff --git a/libs/designer/src/lib/ui/knowledge/modals/__test__/delete.spec.tsx b/libs/designer/src/lib/ui/knowledge/modals/__test__/delete.spec.tsx new file mode 100644 index 00000000000..214fc9fb3e5 --- /dev/null +++ b/libs/designer/src/lib/ui/knowledge/modals/__test__/delete.spec.tsx @@ -0,0 +1,373 @@ +/** + * @vitest-environment jsdom + */ +import { describe, vi, expect, it, beforeEach, afterEach } from 'vitest'; +// biome-ignore lint/correctness/noUnusedImports: using react for render +import React from 'react'; +import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { IntlProvider } from 'react-intl'; +import { DeleteModal } from '../delete'; +import type { KnowledgeHubItem } from '../../wizard/knowledgelist'; + +const mockDeleteKnowledgeHubArtifacts = vi.fn(); + +vi.mock('../../../../core/knowledge/utils/helper', () => ({ + deleteKnowledgeHubArtifacts: (...args: any[]) => mockDeleteKnowledgeHubArtifacts(...args), +})); + +vi.mock('@microsoft/logic-apps-shared', () => ({ + LoggerService: () => ({ + log: vi.fn(), + }), + LogEntryLevel: { + Error: 'Error', + }, +})); + +describe('DeleteModal Component', () => { + const mockOnDelete = vi.fn(); + const mockOnDismiss = vi.fn(); + const defaultResourceId = '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Web/sites/myApp'; + + const createHubItem = (name: string): KnowledgeHubItem => ({ + id: `hub-${name}`, + name, + type: 'hub', + description: `Description for ${name}`, + createdDate: '2024-01-01', + status: 'active', + parentId: null, + isExpanded: false, + }); + + const createArtifactItem = (name: string, parentId: string): KnowledgeHubItem => ({ + id: `artifact-${name}`, + name, + type: 'artifact', + description: `Description for ${name}`, + createdDate: '2024-01-01', + status: 'active', + parentId, + isExpanded: false, + }); + + const defaultProps = { + resourceId: defaultResourceId, + onDelete: mockOnDelete, + onDismiss: mockOnDismiss, + }; + + const renderComponent = (selectedArtifacts: KnowledgeHubItem[], props = {}) => { + const finalProps = { ...defaultProps, selectedArtifacts, ...props }; + return render( + + + + ); + }; + + // Helper to get elements within the non-hidden dialog + const getDialog = () => { + const dialogs = screen.getAllByRole('dialog'); + return dialogs.find((d) => !d.closest('[aria-hidden="true"]'))!; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockDeleteKnowledgeHubArtifacts.mockResolvedValue({}); + }); + + afterEach(() => { + cleanup(); + // Clear any portal elements created by Fluent UI Dialog + document.body.innerHTML = ''; + }); + + describe('Rendering', () => { + it('renders with correct title', () => { + renderComponent([createHubItem('TestHub')]); + + expect(screen.getByText('Delete hub artifacts')).toBeInTheDocument(); + }); + + it('renders hub content when deleting a single hub', () => { + renderComponent([createHubItem('TestHub')]); + + const dialog = getDialog(); + expect( + within(dialog).getByText( + `Confirm that you want to delete this hub? This action also deletes all the hub's artifacts. You can't undo this action.` + ) + ).toBeInTheDocument(); + }); + + it('renders artifact content when deleting a single artifact', () => { + renderComponent([createArtifactItem('TestArtifact', 'ParentHub')]); + + const dialog = getDialog(); + expect(within(dialog).getByText("Confirm that you want to delete this artifact? You can't undo this action.")).toBeInTheDocument(); + }); + + it('renders multi-artifacts content when deleting multiple items', () => { + renderComponent([createHubItem('Hub1'), createArtifactItem('Artifact1', 'OtherHub')]); + + const dialog = getDialog(); + expect( + within(dialog).getByText( + "Confirm that you want to delete these hub artifacts? Deleting a hub deletes all its artifacts. You can't undo this action." + ) + ).toBeInTheDocument(); + }); + + it('displays hub names when deleting hubs', () => { + renderComponent([createHubItem('Hub1'), createHubItem('Hub2')]); + + const dialog = getDialog(); + expect(within(dialog).getByText('Hubs: hub1, hub2')).toBeInTheDocument(); + }); + + it('displays artifact names with parent hub when deleting artifacts', () => { + renderComponent([createArtifactItem('Artifact1', 'ParentHub1'), createArtifactItem('Artifact2', 'ParentHub2')]); + + const dialog = getDialog(); + expect(within(dialog).getByText('Artifacts: artifact1 (hub: ParentHub1), artifact2 (hub: ParentHub2)')).toBeInTheDocument(); + }); + + it('does not display artifacts that belong to hubs being deleted', () => { + // If deleting a hub and its child artifact, the artifact should not be shown separately + renderComponent([createHubItem('ParentHub'), createArtifactItem('ChildArtifact', 'ParentHub')]); + + const dialog = getDialog(); + expect(within(dialog).getByText('Hubs: parenthub')).toBeInTheDocument(); + expect(within(dialog).queryByText(/Artifact\(s\):/)).not.toBeInTheDocument(); + }); + + it('renders delete and continue editing buttons', () => { + renderComponent([createHubItem('TestHub')]); + + const dialog = getDialog(); + expect(within(dialog).getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Continue editing' })).toBeInTheDocument(); + }); + }); + + describe('Delete functionality', () => { + it('calls deleteKnowledgeHubArtifacts with correct parameters when deleting a hub', async () => { + renderComponent([createHubItem('TestHub')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockDeleteKnowledgeHubArtifacts).toHaveBeenCalledWith(defaultResourceId, ['testhub'], {}); + }); + }); + + it('calls deleteKnowledgeHubArtifacts with correct parameters when deleting an artifact', async () => { + renderComponent([createArtifactItem('TestArtifact', 'ParentHub')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockDeleteKnowledgeHubArtifacts).toHaveBeenCalledWith(defaultResourceId, [], { testartifact: 'ParentHub' }); + }); + }); + + it('calls deleteKnowledgeHubArtifacts with both hubs and artifacts', async () => { + renderComponent([createHubItem('Hub1'), createArtifactItem('Artifact1', 'OtherHub')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockDeleteKnowledgeHubArtifacts).toHaveBeenCalledWith(defaultResourceId, ['hub1'], { artifact1: 'OtherHub' }); + }); + }); + + it('calls onDelete callback with notification data after successful deletion', async () => { + renderComponent([createHubItem('TestHub')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalledWith({ + title: 'Successfully deleted the hub artifacts.', + content: 'Deleted the following hub artifacts:\ntesthub', + }); + }); + }); + + it('calls onDelete with artifact names when deleting artifacts', async () => { + renderComponent([createArtifactItem('TestArtifact', 'ParentHub')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalledWith({ + title: 'Successfully deleted the hub artifacts.', + content: 'Deleted the following hub artifacts:\n\ntestartifact (hub: ParentHub)', + }); + }); + }); + + it('calls onDismiss after successful deletion', async () => { + renderComponent([createHubItem('TestHub')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnDismiss).toHaveBeenCalled(); + }); + }); + }); + + describe('Loading state', () => { + it('shows deleting text while deletion is in progress', async () => { + // Create a promise that we can control + let resolveDelete: () => void; + const deletePromise = new Promise((resolve) => { + resolveDelete = resolve; + }); + mockDeleteKnowledgeHubArtifacts.mockReturnValue(deletePromise); + + renderComponent([createHubItem('TestHub')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(within(dialog).getByRole('button', { name: 'Deleting...' })).toBeInTheDocument(); + }); + + // Resolve to clean up + resolveDelete!(); + }); + + it('disables buttons while deletion is in progress', async () => { + let resolveDelete: () => void; + const deletePromise = new Promise((resolve) => { + resolveDelete = resolve; + }); + mockDeleteKnowledgeHubArtifacts.mockReturnValue(deletePromise); + + renderComponent([createHubItem('TestHub')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + const deletingButton = within(dialog).getByRole('button', { name: 'Deleting...' }); + expect(deletingButton).toBeDisabled(); + expect(within(dialog).getByRole('button', { name: 'Continue editing' })).toBeDisabled(); + }); + + // Resolve to clean up + resolveDelete!(); + }); + }); + + describe('Error handling', () => { + it('does not call onDelete or onDismiss when deletion fails', async () => { + mockDeleteKnowledgeHubArtifacts.mockRejectedValue({ error: { message: 'Delete failed' } }); + + renderComponent([createHubItem('TestHub')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockDeleteKnowledgeHubArtifacts).toHaveBeenCalled(); + }); + + // Wait a bit to ensure callbacks are not called + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockOnDelete).not.toHaveBeenCalled(); + expect(mockOnDismiss).not.toHaveBeenCalled(); + }); + + it('resets isDeleting state after error', async () => { + mockDeleteKnowledgeHubArtifacts.mockRejectedValue({ error: { message: 'Delete failed' } }); + + renderComponent([createHubItem('TestHub')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + // Wait for the error to be processed + await waitFor(() => { + expect(within(dialog).getByRole('button', { name: 'Delete' })).not.toBeDisabled(); + }); + }); + }); + + describe('Dismiss functionality', () => { + it('calls onDismiss when continue editing button is clicked', () => { + renderComponent([createHubItem('TestHub')]); + + const dialog = getDialog(); + const continueButton = within(dialog).getByRole('button', { name: 'Continue editing' }); + fireEvent.click(continueButton); + + expect(mockOnDismiss).toHaveBeenCalled(); + }); + }); + + describe('Edge cases', () => { + it('handles artifacts with case-insensitive hub matching', async () => { + // Hub with lowercase name should match parent with different casing + renderComponent([createHubItem('PARENTHUB'), createArtifactItem('Artifact', 'parenthub')]); + + const dialog = getDialog(); + // Artifact should not appear since its parent hub is being deleted + expect(within(dialog).queryByText(/Artifact\(s\):/)).not.toBeInTheDocument(); + }); + + it('handles multiple hubs correctly', async () => { + renderComponent([createHubItem('Hub1'), createHubItem('Hub2'), createHubItem('Hub3')]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockDeleteKnowledgeHubArtifacts).toHaveBeenCalledWith(defaultResourceId, ['hub1', 'hub2', 'hub3'], {}); + }); + }); + + it('handles multiple artifacts from different hubs', async () => { + renderComponent([ + createArtifactItem('Artifact1', 'Hub1'), + createArtifactItem('Artifact2', 'Hub2'), + createArtifactItem('Artifact3', 'Hub1'), + ]); + + const dialog = getDialog(); + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockDeleteKnowledgeHubArtifacts).toHaveBeenCalledWith(defaultResourceId, [], { + artifact1: 'Hub1', + artifact2: 'Hub2', + artifact3: 'Hub1', + }); + }); + }); + }); +}); diff --git a/libs/designer/src/lib/ui/knowledge/modals/delete.tsx b/libs/designer/src/lib/ui/knowledge/modals/delete.tsx new file mode 100644 index 00000000000..9e569355296 --- /dev/null +++ b/libs/designer/src/lib/ui/knowledge/modals/delete.tsx @@ -0,0 +1,194 @@ +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + tokens, + Text, +} from '@fluentui/react-components'; +import { useCallback, useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import type { KnowledgeHubItem } from '../wizard/knowledgelist'; +import type { ServerNotificationData as NotificationData } from '../../mcp/servers/servers'; +import { useModalStyles } from './styles'; +import { deleteKnowledgeHubArtifacts } from '../../../core/knowledge/utils/helper'; +import { LogEntryLevel, LoggerService } from '@microsoft/logic-apps-shared'; + +export const DeleteModal = ({ + selectedArtifacts, + resourceId, + onDelete, + onDismiss, +}: { selectedArtifacts: KnowledgeHubItem[]; resourceId: string; onDelete: (data: NotificationData) => void; onDismiss: () => void }) => { + const intl = useIntl(); + const hubsToDelete: string[] = selectedArtifacts.filter((item) => item.parentId === null).map((item) => item.name.toLowerCase()); + const artifactsToDelete: Record = selectedArtifacts + .filter((item) => item.parentId !== null) + .reduce( + (acc, item) => { + if (!hubsToDelete.includes((item.parentId as string).toLowerCase())) { + acc[item.name.toLowerCase()] = item.parentId as string; + } + return acc; + }, + {} as Record + ); + + const hubNames = useMemo(() => hubsToDelete.join(', '), [hubsToDelete]); + const artifactNames = useMemo( + () => + Object.keys(artifactsToDelete) + .map((key) => `${key} (hub: ${artifactsToDelete[key]})`) + .join(', '), + [artifactsToDelete] + ); + const INTL_TEXT = { + title: intl.formatMessage({ + defaultMessage: 'Delete hub artifacts', + id: 'HMyJSH', + description: 'Title for the delete hub artifacts modal', + }), + multiArtifactsContent: intl.formatMessage({ + defaultMessage: `Confirm that you want to delete these hub artifacts? Deleting a hub deletes all its artifacts. You can't undo this action.`, + id: 'uYxnwQ', + description: 'Content for the delete hub artifacts modal', + }), + hubContent: intl.formatMessage({ + defaultMessage: `Confirm that you want to delete this hub? This action also deletes all the hub's artifacts. You can't undo this action.`, + id: 'Xjhrkz', + description: 'Content for the delete hub', + }), + artifactContent: intl.formatMessage({ + defaultMessage: `Confirm that you want to delete this artifact? You can't undo this action.`, + id: 'R0Skk9', + description: 'Content for the delete artifact', + }), + hubName: intl.formatMessage( + { + defaultMessage: 'Hubs: {hubNames}', + id: 'DAqQK1', + description: 'The name of the hub to be deleted, shown in the delete confirmation modal', + }, + { hubNames } + ), + artifactName: intl.formatMessage( + { + defaultMessage: 'Artifacts: {artifactNames}', + id: 'fm59Od', + description: 'The name of the artifact to be deleted, shown in the delete confirmation modal', + }, + { artifactNames } + ), + deletingButtonText: intl.formatMessage({ + defaultMessage: 'Deleting...', + id: 'HTwVAR', + description: 'Button text for when the hub artifacts are being deleted', + }), + deleteButtonText: intl.formatMessage({ + defaultMessage: 'Delete', + id: 'w1QL1r', + description: 'Button text for deleting the hub artifacts', + }), + closeButtonText: intl.formatMessage({ + defaultMessage: 'Continue editing', + id: 'GYui13', + description: 'Button text for closing the delete hub artifacts modal', + }), + successNotificationTitle: intl.formatMessage({ + defaultMessage: 'Successfully deleted the hub artifacts.', + id: '8iLlRQ', + description: 'Title for the toaster after successfully deleting hub artifacts', + }), + successNotificationContent: intl.formatMessage({ + defaultMessage: 'Deleted the following hub artifacts:', + id: 'H9pzpO', + description: 'Content for the toaster after successfully deleting hub artifacts, with the names of the deleted artifacts', + }), + }; + + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = useCallback(async () => { + try { + setIsDeleting(true); + await deleteKnowledgeHubArtifacts(resourceId, hubsToDelete, artifactsToDelete); + onDelete({ + title: INTL_TEXT.successNotificationTitle, + content: `${INTL_TEXT.successNotificationContent}\n${hubNames}${artifactNames ? `\n${artifactNames}` : ''}`, + }); + onDismiss(); + } catch (errorResponse: any) { + const error = errorResponse?.error || {}; + // For now log the error + LoggerService().log({ + level: LogEntryLevel.Error, + area: 'KnowledgeHub.deleteKnowledgeHubArtifact', + error, + message: `The following error happened when deleting the knowledge hub artifact for the app: ${resourceId}`, + }); + } finally { + setIsDeleting(false); + } + }, [ + INTL_TEXT.successNotificationContent, + INTL_TEXT.successNotificationTitle, + artifactNames, + artifactsToDelete, + hubNames, + hubsToDelete, + onDelete, + onDismiss, + resourceId, + ]); + + const styles = useModalStyles(); + return ( + + + + {INTL_TEXT.title} + + {selectedArtifacts.length > 1 ? ( +
+ {INTL_TEXT.multiArtifactsContent} +
+ {hubsToDelete.length > 0 && ( + <> + {INTL_TEXT.hubName} +
+ + )} + {Object.keys(artifactsToDelete).length > 0 && {INTL_TEXT.artifactName}} +
+ ) : selectedArtifacts[0].parentId === null ? ( + INTL_TEXT.hubContent + ) : ( + INTL_TEXT.artifactContent + )} +
+ + + + + + +
+
+
+ ); +}; diff --git a/libs/designer/src/lib/ui/knowledge/modals/styles.ts b/libs/designer/src/lib/ui/knowledge/modals/styles.ts new file mode 100644 index 00000000000..e5384cd4c7c --- /dev/null +++ b/libs/designer/src/lib/ui/knowledge/modals/styles.ts @@ -0,0 +1,9 @@ +import { makeStyles, tokens } from '@fluentui/react-components'; + +export const useModalStyles = makeStyles({ + content: { + display: 'flex', + flexDirection: 'column', + paddingTop: tokens.spacingVerticalL, + }, +}); diff --git a/libs/designer/src/lib/ui/knowledge/wizard/__test__/knowledgehub.spec.tsx b/libs/designer/src/lib/ui/knowledge/wizard/__test__/knowledgehub.spec.tsx index 793766064ec..4bf967a10d9 100644 --- a/libs/designer/src/lib/ui/knowledge/wizard/__test__/knowledgehub.spec.tsx +++ b/libs/designer/src/lib/ui/knowledge/wizard/__test__/knowledgehub.spec.tsx @@ -10,7 +10,7 @@ import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import { KnowledgeHubWizard } from '../knowledgehub'; -import { KnowledgePanelView } from '../../../../core/state/knowledge/panelSlice'; +import type { KnowledgeHubItem } from '../knowledgelist'; // Mock setLayerHostSelector vi.mock('@fluentui/react', () => ({ @@ -45,11 +45,82 @@ vi.mock('../../panel/panelroot', () => ({ vi.mock('../../modals/creategroup', () => ({ CreateGroup: ({ onDismiss }: { onDismiss: () => void }) => (
- +
), })); +// Mock DeleteModal +vi.mock('../../modals/delete', () => ({ + DeleteModal: ({ + selectedArtifacts, + onDelete, + onDismiss, + }: { + selectedArtifacts: KnowledgeHubItem[]; + resourceId: string; + onDelete: () => void; + onDismiss: () => void; + }) => ( +
+ {selectedArtifacts.length} items selected + + +
+ ), +})); + +// Mock KnowledgeList +let mockSetSelectedArtifacts: ((items: KnowledgeHubItem[]) => void) | null = null; +vi.mock('../knowledgelist', () => ({ + KnowledgeList: ({ + resourceId, + hubs, + setSelectedArtifacts, + }: { + resourceId: string; + hubs: any[]; + onUploadArtifacts: () => void; + setSelectedArtifacts: (items: KnowledgeHubItem[]) => void; + }) => { + mockSetSelectedArtifacts = setSelectedArtifacts; + return ( +
+ {resourceId} + {hubs.length} hubs + + +
+ ); + }, +})); + // Mock DescriptionWithLink vi.mock('../../../configuretemplate/common', () => ({ DescriptionWithLink: ({ text, linkText }: { text: string; linkText?: string }) => ( @@ -113,251 +184,625 @@ describe('KnowledgeHubWizard Component', () => { beforeEach(() => { vi.clearAllMocks(); mockRefetch.mockResolvedValue({}); + mockSetSelectedArtifacts = null; }); afterEach(() => { cleanup(); }); - it('shows loading spinner when data is loading', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: undefined, - isLoading: true, - refetch: mockRefetch, - }); - mockUseConnection.mockReturnValue({ - data: undefined, - isLoading: true, + describe('Loading State', () => { + it('shows loading spinner when data is loading', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: undefined, + isLoading: true, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: undefined, + isLoading: true, + }); + + renderComponent(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); }); - renderComponent(); + it('shows loading spinner when only connection is loading', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: undefined, + isLoading: true, + }); - expect(screen.getByText('Loading...')).toBeInTheDocument(); - }); + renderComponent(); - it('shows loading spinner when connection is loading', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, - }); - mockUseConnection.mockReturnValue({ - data: undefined, - isLoading: true, + expect(screen.getByText('Loading...')).toBeInTheDocument(); }); - renderComponent(); + it('shows loading spinner when hubs are undefined', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: undefined, + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); - expect(screen.getByText('Loading...')).toBeInTheDocument(); - }); + renderComponent(); - it('renders main content when loaded with connection and empty hubs', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, + expect(screen.getByText('Loading...')).toBeInTheDocument(); }); - mockUseConnection.mockReturnValue({ - data: { id: 'connection1' }, - isLoading: false, + }); + + describe('Main Content Rendering', () => { + it('renders main content when loaded with connection and empty hubs', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByTestId('knowledge-hub-panel')).toBeInTheDocument(); + expect(screen.getByText('New')).toBeInTheDocument(); + expect(screen.getByText('Refresh')).toBeInTheDocument(); + expect(screen.getByText('Connection')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); }); - renderComponent(); + it('renders description with learn more link', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); - expect(screen.getByTestId('knowledge-hub-panel')).toBeInTheDocument(); - expect(screen.getByText('New')).toBeInTheDocument(); - expect(screen.getByText('Refresh')).toBeInTheDocument(); - expect(screen.getByText('Connection')).toBeInTheDocument(); - expect(screen.getByText('Delete')).toBeInTheDocument(); - }); + renderComponent(); - it('renders NoConnectionsView when no connection exists', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, + expect(screen.getByText('Learn more about knowledge sources')).toBeInTheDocument(); }); - mockUseConnection.mockReturnValue({ - data: null, - isLoading: false, + }); + + describe('NoConnectionsView', () => { + it('renders NoConnectionsView when no connection exists', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: null, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText('Ground responses and insights with knowledge')).toBeInTheDocument(); + expect(screen.getByText('Set up')).toBeInTheDocument(); }); - renderComponent(); + it('dispatches CreateConnection when Set up button clicked', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: null, + isLoading: false, + }); - expect(screen.getByText('Ground responses and insights with knowledge')).toBeInTheDocument(); - expect(screen.getByText('Set up')).toBeInTheDocument(); - }); + renderComponent(); - it('renders EmptyKnowledgeBaseView when connection exists but no hubs', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, - }); - mockUseConnection.mockReturnValue({ - data: { id: 'connection1' }, - isLoading: false, + const setupButton = screen.getByText('Set up'); + fireEvent.click(setupButton); + + expect(mockOpenPanelView).toHaveBeenCalledWith({ panelView: 'createConnection' }); }); - renderComponent(); + it('uses dark mode image when isDarkMode is true', () => { + const darkModeStore = createMockStore({ subscriptionId: 'sub1', resourceGroup: 'rg1', logicAppName: 'myApp' }, { isDarkMode: true }); - expect(screen.getByText('Add file sources to your Knowledge base')).toBeInTheDocument(); - expect(screen.getByText('Add files')).toBeInTheDocument(); - }); + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: null, + isLoading: false, + }); - it('calls refetch when Refresh button is clicked', async () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, - }); - mockUseConnection.mockReturnValue({ - data: { id: 'connection1' }, - isLoading: false, + renderComponent(darkModeStore); + + expect(screen.getByText('Ground responses and insights with knowledge')).toBeInTheDocument(); }); - renderComponent(); + it('disables Connection and Delete buttons when no connection exists', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: null, + isLoading: false, + }); - const refreshButton = screen.getByText('Refresh'); - fireEvent.click(refreshButton); + renderComponent(); - await waitFor(() => { - expect(mockRefetch).toHaveBeenCalled(); + const connectionButton = screen.getByText('Connection'); + const deleteButton = screen.getByText('Delete'); + + expect(connectionButton.closest('button')).toBeDisabled(); + expect(deleteButton.closest('button')).toBeDisabled(); }); }); - it('dispatches openPanelView for EditConnection when Connection button clicked with existing connection', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, + describe('EmptyKnowledgeBaseView', () => { + it('renders EmptyKnowledgeBaseView when connection exists but no hubs', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText('Add file sources to your Knowledge base')).toBeInTheDocument(); }); - mockUseConnection.mockReturnValue({ - data: { id: 'connection1' }, - isLoading: false, + + it('dispatches AddFiles when Add files button clicked', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + // Find the primary Add files button in EmptyKnowledgeBaseView + const addFilesButtons = screen.getAllByText('Add files'); + const primaryButton = addFilesButtons.find((btn) => btn.closest('button')?.className.includes('primary')); + + if (primaryButton) { + fireEvent.click(primaryButton); + expect(mockOpenPanelView).toHaveBeenCalledWith({ panelView: 'addFiles' }); + } }); - renderComponent(); + it('uses dark mode image when isDarkMode is true', () => { + const darkModeStore = createMockStore({ subscriptionId: 'sub1', resourceGroup: 'rg1', logicAppName: 'myApp' }, { isDarkMode: true }); - const connectionButton = screen.getByText('Connection'); - fireEvent.click(connectionButton); + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); - expect(mockOpenPanelView).toHaveBeenCalledWith({ panelView: 'editConnection' }); - }); + renderComponent(darkModeStore); - it('renders description with learn more link', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, + expect(screen.getByText('Add file sources to your Knowledge base')).toBeInTheDocument(); }); - mockUseConnection.mockReturnValue({ - data: { id: 'connection1' }, - isLoading: false, + }); + + describe('KnowledgeList View', () => { + it('renders KnowledgeList when hubs exist', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1', id: 'hub1' }], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByTestId('knowledge-list')).toBeInTheDocument(); + expect(screen.getByTestId('knowledge-list-count')).toHaveTextContent('1 hubs'); }); - renderComponent(); + it('passes correct resourceId to KnowledgeList', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1', id: 'hub1' }], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByTestId('knowledge-list-resource')).toHaveTextContent( + '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Web/sites/myApp' + ); + }); - expect(screen.getByText('Learn more about knowledge sources')).toBeInTheDocument(); + it('renders multiple hubs correctly', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [ + { name: 'Hub1', id: 'hub1' }, + { name: 'Hub2', id: 'hub2' }, + { name: 'Hub3', id: 'hub3' }, + ], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByTestId('knowledge-list-count')).toHaveTextContent('3 hubs'); + }); }); - it('disables menu items when no connection exists', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, - }); - mockUseConnection.mockReturnValue({ - data: null, - isLoading: false, + describe('Refresh Functionality', () => { + it('calls refetch when Refresh button is clicked', async () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + const refreshButton = screen.getByText('Refresh'); + fireEvent.click(refreshButton); + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalled(); + }); }); + }); - renderComponent(); + describe('Connection Button', () => { + it('dispatches openPanelView for EditConnection when clicked with existing connection', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); - const connectionButton = screen.getByText('Connection'); - const deleteButton = screen.getByText('Delete'); + renderComponent(); - expect(connectionButton.closest('button')).toBeDisabled(); - expect(deleteButton.closest('button')).toBeDisabled(); + const connectionButton = screen.getByText('Connection'); + fireEvent.click(connectionButton); + + expect(mockOpenPanelView).toHaveBeenCalledWith({ panelView: 'editConnection' }); + }); }); - it('renders hubs list view when hubs exist', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [{ name: 'Hub1', id: 'hub1' }], - isLoading: false, - refetch: mockRefetch, + describe('Delete Modal', () => { + it('Delete button is disabled when no artifacts are selected', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1', id: 'hub1' }], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + const deleteButton = screen.getByText('Delete'); + expect(deleteButton.closest('button')).toBeDisabled(); }); - mockUseConnection.mockReturnValue({ - data: { id: 'connection1' }, - isLoading: false, + + it('Delete button becomes enabled when artifacts are selected', async () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1', id: 'hub1' }], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + // Select an item via the list + const selectButton = screen.getByTestId('knowledge-list-select-item'); + fireEvent.click(selectButton); + + await waitFor(() => { + const deleteButton = screen.getByText('Delete'); + expect(deleteButton.closest('button')).not.toBeDisabled(); + }); }); - renderComponent(); + it('shows DeleteModal when Delete button is clicked with selected artifacts', async () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1', id: 'hub1' }], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + // Select an item + const selectButton = screen.getByTestId('knowledge-list-select-item'); + fireEvent.click(selectButton); + + await waitFor(() => { + const deleteButton = screen.getByText('Delete'); + expect(deleteButton.closest('button')).not.toBeDisabled(); + }); + + // Click delete + const deleteButton = screen.getByText('Delete'); + fireEvent.click(deleteButton); + + expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); + expect(screen.getByTestId('delete-modal-count')).toHaveTextContent('1 items selected'); + }); - expect(screen.getByText('Open the list view here')).toBeInTheDocument(); - }); + it('closes DeleteModal when cancel is clicked', async () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1', id: 'hub1' }], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); - it('uses correct image based on dark mode', () => { - const darkModeStore = createMockStore({ subscriptionId: 'sub1', resourceGroup: 'rg1', logicAppName: 'myApp' }, { isDarkMode: true }); + renderComponent(); - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, - }); - mockUseConnection.mockReturnValue({ - data: null, - isLoading: false, + // Select an item and open delete modal + fireEvent.click(screen.getByTestId('knowledge-list-select-item')); + + await waitFor(() => { + expect(screen.getByText('Delete').closest('button')).not.toBeDisabled(); + }); + + fireEvent.click(screen.getByText('Delete')); + expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); + + // Cancel the modal + fireEvent.click(screen.getByTestId('delete-modal-cancel')); + + await waitFor(() => { + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); + }); }); - renderComponent(darkModeStore); + it('calls refetch and clears selection when delete is confirmed', async () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1', id: 'hub1' }], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); - // NoConnectionsView should be visible in dark mode - expect(screen.getByText('Ground responses and insights with knowledge')).toBeInTheDocument(); - }); + renderComponent(); + + // Select an item and open delete modal + fireEvent.click(screen.getByTestId('knowledge-list-select-item')); + + await waitFor(() => { + expect(screen.getByText('Delete').closest('button')).not.toBeDisabled(); + }); - it('dispatches CreateConnection when Set up button clicked in NoConnectionsView', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, + fireEvent.click(screen.getByText('Delete')); + + // Confirm deletion + fireEvent.click(screen.getByTestId('delete-modal-confirm')); + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalled(); + }); }); - mockUseConnection.mockReturnValue({ - data: null, - isLoading: false, + }); + + describe('CreateGroup Modal', () => { + it('shows CreateGroup modal when "Create new group" menu item is clicked', async () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + // Open the menu + const newButton = screen.getByText('New'); + fireEvent.click(newButton); + + // Click "Create new group" + const createGroupItem = await screen.findByText('Create new group'); + fireEvent.click(createGroupItem); + + expect(screen.getByTestId('create-group-modal')).toBeInTheDocument(); }); - renderComponent(); + it('closes CreateGroup modal when close button is clicked', async () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); - const setupButton = screen.getByText('Set up'); - fireEvent.click(setupButton); + renderComponent(); - expect(mockOpenPanelView).toHaveBeenCalledWith({ panelView: 'createConnection' }); - }); + // Open the menu and click create group + fireEvent.click(screen.getByText('New')); + const createGroupItem = await screen.findByText('Create new group'); + fireEvent.click(createGroupItem); + + expect(screen.getByTestId('create-group-modal')).toBeInTheDocument(); + + // Close the modal + fireEvent.click(screen.getByTestId('create-group-close')); - it('dispatches AddFiles when Add files button clicked in EmptyKnowledgeBaseView', () => { - mockUseAllKnowledgeHubs.mockReturnValue({ - data: [], - isLoading: false, - refetch: mockRefetch, + await waitFor(() => { + expect(screen.queryByTestId('create-group-modal')).not.toBeInTheDocument(); + }); }); - mockUseConnection.mockReturnValue({ - data: { id: 'connection1' }, - isLoading: false, + }); + + describe('Menu Items', () => { + it('menu items are disabled when no connection exists', async () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: null, + isLoading: false, + }); + + renderComponent(); + + // Open the menu + const newButton = screen.getByText('New'); + fireEvent.click(newButton); + + // Check that menu items are disabled + const addFilesItems = await screen.findAllByText('Add files'); + const addFilesMenuItem = addFilesItems.find((el) => el.closest('[role="menuitem"]')); + const createGroupItem = await screen.findByText('Create new group'); + + expect(addFilesMenuItem?.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true'); + expect(createGroupItem.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true'); }); - renderComponent(); + it('menu items are enabled when connection exists', async () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1', id: 'hub1' }], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(); + + // Open the menu + const newButton = screen.getByText('New'); + fireEvent.click(newButton); + + // Check that menu items are not disabled - use getAllByText since there are multiple "Add files" + const addFilesItems = await screen.findAllByText('Add files'); + const addFilesMenuItem = addFilesItems.find((el) => el.closest('[role="menuitem"]')); + const createGroupItem = await screen.findByText('Create new group'); + + expect(addFilesMenuItem?.closest('[role="menuitem"]')).not.toHaveAttribute('aria-disabled', 'true'); + expect(createGroupItem.closest('[role="menuitem"]')).not.toHaveAttribute('aria-disabled', 'true'); + }); + }); - // Find the button specifically in the empty view (not the menu item) - const addFilesButtons = screen.getAllByText('Add files'); - // The button in EmptyKnowledgeBaseView - const emptyViewButton = addFilesButtons.find( - (btn) => btn.closest('button')?.getAttribute('appearance') === 'primary' || btn.closest('[class*="emptyViewButtons"]') - ); + describe('Resource ID Calculation', () => { + it('correctly computes logicAppId from store values', () => { + const customStore = createMockStore({ + subscriptionId: 'custom-sub', + resourceGroup: 'custom-rg', + logicAppName: 'customApp', + }); + + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1', id: 'hub1' }], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(customStore); + + expect(screen.getByTestId('knowledge-list-resource')).toHaveTextContent( + '/subscriptions/custom-sub/resourceGroups/custom-rg/providers/Microsoft.Web/sites/customApp' + ); + }); - if (emptyViewButton) { - fireEvent.click(emptyViewButton); - expect(mockOpenPanelView).toHaveBeenCalledWith({ panelView: 'addFiles' }); - } + it('handles empty logicAppName', () => { + const customStore = createMockStore({ + subscriptionId: 'sub1', + resourceGroup: 'rg1', + logicAppName: null, + }); + + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1', id: 'hub1' }], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { id: 'connection1' }, + isLoading: false, + }); + + renderComponent(customStore); + + expect(screen.getByTestId('knowledge-list-resource')).toHaveTextContent( + '/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Web/sites/' + ); + }); }); }); diff --git a/libs/designer/src/lib/ui/knowledge/wizard/__test__/knowledgelist.spec.tsx b/libs/designer/src/lib/ui/knowledge/wizard/__test__/knowledgelist.spec.tsx new file mode 100644 index 00000000000..adb6a7b88b2 --- /dev/null +++ b/libs/designer/src/lib/ui/knowledge/wizard/__test__/knowledgelist.spec.tsx @@ -0,0 +1,597 @@ +/** + * @vitest-environment jsdom + */ +import { describe, vi, expect, it, beforeEach, afterEach } from 'vitest'; +// biome-ignore lint/correctness/noUnusedImports: using react for render +import React from 'react'; +import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { IntlProvider } from 'react-intl'; +import { KnowledgeList, type KnowledgeHubItem } from '../knowledgelist'; +import { ArtifactCreationStatus, type KnowledgeHubExtended as KnowledgeHub } from '@microsoft/logic-apps-shared'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Mock styles +vi.mock('../styles', () => ({ + useListStyles: () => ({ + tableStyle: 'mock-table', + tableCell: 'mock-table-cell', + rowCell: 'mock-row-cell', + nameCell: 'mock-name-cell', + nameText: 'mock-name-text', + artifactNameCell: 'mock-artifact-name-cell', + statusCell: 'mock-status-cell', + actionCell: 'mock-action-cell', + iconsCell: 'mock-icons-cell', + }), +})); + +// Mock DeleteModal +vi.mock('../../modals/delete', () => ({ + DeleteModal: ({ + selectedArtifacts, + onDelete, + onDismiss, + }: { + selectedArtifacts: KnowledgeHubItem[]; + resourceId: string; + onDelete: () => void; + onDismiss: () => void; + }) => ( +
+ {selectedArtifacts.length} items selected + + +
+ ), +})); + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + +const renderWithProviders = (component: React.ReactElement, queryClient?: QueryClient) => { + const client = queryClient ?? createTestQueryClient(); + return render( + + {component} + + ); +}; + +describe('KnowledgeList', () => { + const mockSetSelectedArtifacts = vi.fn(); + const mockOnUploadArtifacts = vi.fn(); + const mockResourceId = '/subscriptions/test-sub/resourceGroups/test-rg'; + + const createMockHub = (overrides?: Partial): KnowledgeHub => ({ + name: 'TestHub', + id: '/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app/knowledgeHubs/TestHub', + type: 'Microsoft.Web/sites/knowledgeHubs', + properties: { + createdTime: '2024-01-15T10:00:00Z', + description: 'A test knowledge hub', + }, + artifacts: [], + ...overrides, + }); + + const createMockHubWithArtifacts = (): KnowledgeHub => ({ + name: 'HubWithArtifacts', + id: '/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app/knowledgeHubs/HubWithArtifacts', + type: 'Microsoft.Web/sites/knowledgeHubs', + properties: { + createdTime: '2024-01-15T10:00:00Z', + description: 'A hub with artifacts', + }, + artifacts: [ + { + name: 'Artifact1', + description: 'First artifact', + createdAt: '2024-01-16T10:00:00Z', + uploadStatus: ArtifactCreationStatus.Completed, + }, + { + name: 'Artifact2', + description: 'Second artifact', + createdAt: '2024-01-17T10:00:00Z', + uploadStatus: ArtifactCreationStatus.InProgress, + }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Rendering', () => { + it('should render knowledge hub names in the table', () => { + const mockHubs: KnowledgeHub[] = [createMockHub()]; + + renderWithProviders( + + ); + + expect(screen.getByText('TestHub')).toBeInTheDocument(); + }); + + it('should render nothing when hubs array is empty', () => { + const { container } = renderWithProviders( + + ); + + expect(container.querySelector('table')).not.toBeInTheDocument(); + }); + + it('should render multiple hubs', () => { + const mockHubs: KnowledgeHub[] = [createMockHub({ name: 'Hub1' }), createMockHub({ name: 'Hub2' }), createMockHub({ name: 'Hub3' })]; + + renderWithProviders( + + ); + + expect(screen.getByText('Hub1')).toBeInTheDocument(); + expect(screen.getByText('Hub2')).toBeInTheDocument(); + expect(screen.getByText('Hub3')).toBeInTheDocument(); + }); + + it('should render table headers', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Created')).toBeInTheDocument(); + expect(screen.getByText('Upload status')).toBeInTheDocument(); + }); + + it('should render hub description', () => { + const mockHubs: KnowledgeHub[] = [createMockHub({ description: 'Custom description' })]; + + renderWithProviders( + + ); + + expect(screen.getByText('Custom description')).toBeInTheDocument(); + }); + }); + + describe('Expand/Collapse', () => { + it('should show artifacts when hub is expanded', () => { + const mockHubs: KnowledgeHub[] = [createMockHubWithArtifacts()]; + + renderWithProviders( + + ); + + // Initially artifacts should not be visible + expect(screen.queryByText('Artifact1')).not.toBeInTheDocument(); + + // Find and click the expand button (first button in the row) + const hubRow = screen.getByText('HubWithArtifacts').closest('tr'); + const expandButton = within(hubRow!).getAllByRole('button')[0]; + fireEvent.click(expandButton); + + // Artifacts should now be visible + expect(screen.getByText('Artifact1')).toBeInTheDocument(); + expect(screen.getByText('Artifact2')).toBeInTheDocument(); + }); + + it('should hide artifacts when hub is collapsed', () => { + const mockHubs: KnowledgeHub[] = [createMockHubWithArtifacts()]; + + renderWithProviders( + + ); + + // Expand first + const hubRow = screen.getByText('HubWithArtifacts').closest('tr'); + const expandButton = within(hubRow!).getAllByRole('button')[0]; + fireEvent.click(expandButton); + expect(screen.getByText('Artifact1')).toBeInTheDocument(); + + // Collapse + fireEvent.click(expandButton); + + // Artifacts should be hidden + expect(screen.queryByText('Artifact1')).not.toBeInTheDocument(); + }); + }); + + describe('Row Selection', () => { + it('should call setSelectedArtifacts when a row is clicked', () => { + const mockHubs: KnowledgeHub[] = [createMockHub()]; + + renderWithProviders( + + ); + + const row = screen.getByText('TestHub').closest('tr'); + expect(row).toBeInTheDocument(); + fireEvent.click(row!); + + expect(mockSetSelectedArtifacts).toHaveBeenCalled(); + }); + + it('should toggle selection when row is clicked again', () => { + const mockHubs: KnowledgeHub[] = [createMockHub()]; + + renderWithProviders( + + ); + + const row = screen.getByText('TestHub').closest('tr'); + fireEvent.click(row!); + fireEvent.click(row!); + + // Should be called twice - once to select, once to deselect + expect(mockSetSelectedArtifacts).toHaveBeenCalledTimes(2); + }); + + it('should select row when space key is pressed', () => { + const mockHubs: KnowledgeHub[] = [createMockHub()]; + + renderWithProviders( + + ); + + const row = screen.getByText('TestHub').closest('tr'); + fireEvent.keyDown(row!, { key: ' ' }); + + expect(mockSetSelectedArtifacts).toHaveBeenCalled(); + }); + }); + + describe('Status Display', () => { + it('should display completed status for artifacts', () => { + const mockHubs: KnowledgeHub[] = [createMockHubWithArtifacts()]; + + renderWithProviders( + + ); + + // Expand to see artifacts + const hubRow = screen.getByText('HubWithArtifacts').closest('tr'); + const expandButton = within(hubRow!).getAllByRole('button')[0]; + fireEvent.click(expandButton); + + expect(screen.getByText('Complete')).toBeInTheDocument(); + }); + + it('should display in-progress status for artifacts', () => { + const mockHubs: KnowledgeHub[] = [createMockHubWithArtifacts()]; + + renderWithProviders( + + ); + + // Expand to see artifacts + const hubRow = screen.getByText('HubWithArtifacts').closest('tr'); + const expandButton = within(hubRow!).getAllByRole('button')[0]; + fireEvent.click(expandButton); + + expect(screen.getByText('In progress')).toBeInTheDocument(); + }); + + it('should display error status for failed artifacts', () => { + const mockHubs: KnowledgeHub[] = [ + { + ...createMockHub(), + artifacts: [ + { + name: 'FailedArtifact', + description: 'Failed upload', + createdAt: '2024-01-16T10:00:00Z', + uploadStatus: ArtifactCreationStatus.Failed, + }, + ], + }, + ]; + + renderWithProviders( + + ); + + // Expand to see artifacts + const hubRow = screen.getByText('TestHub').closest('tr'); + const expandButton = within(hubRow!).getAllByRole('button')[0]; + fireEvent.click(expandButton); + + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + }); + + describe('Context Menu Actions', () => { + it('should show delete modal when delete is clicked from context menu', async () => { + const mockHubs: KnowledgeHub[] = [createMockHub()]; + + renderWithProviders( + + ); + + // Find the hub row and click the context menu button (last button in the row) + const hubRow = screen.getByText('TestHub').closest('tr'); + const buttons = within(hubRow!).getAllByRole('button'); + const moreButton = buttons[buttons.length - 1]; + fireEvent.click(moreButton); + + // Click delete option + const deleteButton = await screen.findByRole('menuitem', { name: /delete/i }); + fireEvent.click(deleteButton); + + expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); + }); + + it('should call onUploadArtifacts when upload is clicked for a hub', async () => { + const mockHubs: KnowledgeHub[] = [createMockHub()]; + + renderWithProviders( + + ); + + // Find the hub row and click the context menu button + const hubRow = screen.getByText('TestHub').closest('tr'); + const buttons = within(hubRow!).getAllByRole('button'); + const moreButton = buttons[buttons.length - 1]; + fireEvent.click(moreButton); + + // Click upload option + const uploadButton = await screen.findByRole('menuitem', { name: /upload artifacts/i }); + fireEvent.click(uploadButton); + + expect(mockOnUploadArtifacts).toHaveBeenCalledWith(mockHubs[0]); + }); + }); + + describe('Delete Modal', () => { + it('should close delete modal when cancel is clicked', async () => { + const mockHubs: KnowledgeHub[] = [createMockHub()]; + + renderWithProviders( + + ); + + // Open context menu and click delete + const hubRow = screen.getByText('TestHub').closest('tr'); + const buttons = within(hubRow!).getAllByRole('button'); + const moreButton = buttons[buttons.length - 1]; + fireEvent.click(moreButton); + + const deleteButton = await screen.findByRole('menuitem', { name: /delete/i }); + fireEvent.click(deleteButton); + + // Verify modal is open + expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); + + // Click cancel + const cancelButton = screen.getByTestId('delete-modal-cancel'); + fireEvent.click(cancelButton); + + // Modal should be closed + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); + }); + + it('should close delete modal after confirm delete', async () => { + const queryClient = createTestQueryClient(); + const mockHubs: KnowledgeHub[] = [createMockHub()]; + + renderWithProviders( + , + queryClient + ); + + // Open context menu and click delete + const hubRow = screen.getByText('TestHub').closest('tr'); + const buttons = within(hubRow!).getAllByRole('button'); + const moreButton = buttons[buttons.length - 1]; + fireEvent.click(moreButton); + + const deleteButton = await screen.findByRole('menuitem', { name: /delete/i }); + fireEvent.click(deleteButton); + + // Click confirm + const confirmButton = screen.getByTestId('delete-modal-confirm'); + fireEvent.click(confirmButton); + + // Modal should be closed + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); + }); + }); + + describe('Select All', () => { + it('should have a select all checkbox in the header', () => { + const mockHubs: KnowledgeHub[] = [createMockHub()]; + + renderWithProviders( + + ); + + // Find the select all checkbox by aria-label + const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all/i }); + expect(selectAllCheckbox).toBeInTheDocument(); + }); + + it('should select all items when select all checkbox is clicked', () => { + const mockHubs: KnowledgeHub[] = [createMockHub({ name: 'Hub1' }), createMockHub({ name: 'Hub2' })]; + + renderWithProviders( + + ); + + const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all/i }); + fireEvent.click(selectAllCheckbox); + + expect(mockSetSelectedArtifacts).toHaveBeenCalled(); + }); + }); + + describe('Artifact Display', () => { + it('should display artifact description when expanded', () => { + const mockHubs: KnowledgeHub[] = [createMockHubWithArtifacts()]; + + renderWithProviders( + + ); + + // Expand hub + const hubRow = screen.getByText('HubWithArtifacts').closest('tr'); + const expandButton = within(hubRow!).getAllByRole('button')[0]; + fireEvent.click(expandButton); + + expect(screen.getByText('First artifact')).toBeInTheDocument(); + expect(screen.getByText('Second artifact')).toBeInTheDocument(); + }); + + it('should display file type for artifacts', () => { + const mockHubs: KnowledgeHub[] = [createMockHubWithArtifacts()]; + + renderWithProviders( + + ); + + // Expand hub + const hubRow = screen.getByText('HubWithArtifacts').closest('tr'); + const expandButton = within(hubRow!).getAllByRole('button')[0]; + fireEvent.click(expandButton); + + // 'file' type should appear for artifacts + const fileCells = screen.getAllByText('file'); + expect(fileCells.length).toBeGreaterThan(0); + }); + + it('should display folder type for hubs', () => { + const mockHubs: KnowledgeHub[] = [createMockHub()]; + + renderWithProviders( + + ); + + expect(screen.getByText('folder')).toBeInTheDocument(); + }); + }); +}); diff --git a/libs/designer/src/lib/ui/knowledge/wizard/knowledgehub.tsx b/libs/designer/src/lib/ui/knowledge/wizard/knowledgehub.tsx index ef15575ae47..770b1bb0f38 100644 --- a/libs/designer/src/lib/ui/knowledge/wizard/knowledgehub.tsx +++ b/libs/designer/src/lib/ui/knowledge/wizard/knowledgehub.tsx @@ -3,7 +3,7 @@ import type { AppDispatch, RootState } from '../../../core/state/knowledge/store import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { setLayerHostSelector } from '@fluentui/react'; import { useIntl } from 'react-intl'; -import type { KnowledgeHub } from '@microsoft/logic-apps-shared'; +import type { KnowledgeHubExtended as KnowledgeHub } from '@microsoft/logic-apps-shared'; import { getStandardLogicAppId } from '../../../core/configuretemplate/utils/helper'; import { KnowledgePanelView, openPanelView } from '../../../core/state/knowledge/panelSlice'; import { @@ -36,6 +36,8 @@ import { LinkMultipleRegular, } from '@fluentui/react-icons'; import { CreateGroup } from '../modals/creategroup'; +import { type KnowledgeHubItem, KnowledgeList } from './knowledgelist'; +import { DeleteModal } from '../modals/delete'; export const KnowledgeHubWizard = () => { useEffect(() => setLayerHostSelector('#msla-layer-host'), []); @@ -99,6 +101,8 @@ export const KnowledgeHubWizard = () => { const [hubs, setHubs] = useState(undefined); const [showAddGroup, setShowAddGroup] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [selectedArtifacts, setSelectedArtifacts] = useState([]); useEffect(() => { if (allHubs && !isLoading) { @@ -106,25 +110,22 @@ export const KnowledgeHubWizard = () => { } }, [allHubs, isLoading]); - const handleDelete = useCallback(() => { - // Implement delete functionality here - }, []); - const handleAddFiles = useCallback(() => { // Implement add files functionality here }, []); - const handleAddGroup = useCallback(() => { - setShowAddGroup(true); - }, []); - const handleCloseAddGroup = useCallback(() => { - setShowAddGroup(false); - }, []); - - const handleRefreshHubs = useCallback(async () => { + const handleDeleteClick = useCallback(() => setShowDeleteModal(true), []); + const handleCloseDeleteModal = useCallback(() => setShowDeleteModal(false), []); + const handleOnDeleteComplete = useCallback(async () => { await refetch(); + setSelectedArtifacts([]); }, [refetch]); + const handleAddGroup = useCallback(() => setShowAddGroup(true), []); + const handleCloseAddGroup = useCallback(() => setShowAddGroup(false), []); + + const handleRefreshHubs = useCallback(async () => refetch(), [refetch]); + const handleConnectionClick = useCallback(() => { dispatch(openPanelView({ panelView: connection ? KnowledgePanelView.EditConnection : KnowledgePanelView.CreateConnection })); }, [dispatch, connection]); @@ -174,12 +175,38 @@ export const KnowledgeHubWizard = () => { - - {hubs.length === 0 ? connection ? : :
{'Open the list view here'}
} + {hubs.length === 0 ? ( + connection ? ( + + ) : ( + + ) + ) : ( + + )} {showAddGroup ? : null} + {showDeleteModal ? ( + + ) : null}
void; + onUploadArtifacts: (hub: KnowledgeHub) => void; +}) => { + const intl = useIntl(); + const styles = useListStyles(); + const queryClient = useQueryClient(); + + const INTL_TEXT = { + tableAriaLabel: intl.formatMessage({ + defaultMessage: 'List of knowledge hubs', + id: 'JLWOQY', + description: 'The aria label for the knowledge hubs table', + }), + nameLabel: intl.formatMessage({ + defaultMessage: 'Name', + id: '5m1Ozg', + description: 'The label for the name column', + }), + typeLabel: intl.formatMessage({ + defaultMessage: 'Type', + id: 'XXOaU8', + description: 'The label for the type column', + }), + agentLabel: intl.formatMessage({ + defaultMessage: 'Agent', + id: 'IOAsSh', + description: 'Label for the agent column', + }), + descriptionLabel: intl.formatMessage({ + defaultMessage: 'Description', + id: 'Uf1R8k', + description: 'Label for the description column', + }), + createdDateLabel: intl.formatMessage({ + defaultMessage: 'Created', + id: 'Lsac0i', + description: 'Label for the created date column', + }), + statusLabel: intl.formatMessage({ + defaultMessage: 'Upload status', + id: 'xfUoo5', + description: 'Label for the status column', + }), + uploadLabel: intl.formatMessage({ + defaultMessage: 'Upload artifacts', + id: 'mfpHrs', + description: 'Label for the upload artifacts action', + }), + deleteLabel: intl.formatMessage({ + defaultMessage: 'Delete', + id: '8M2YfK', + description: 'Label for the delete action', + }), + selectAll: intl.formatMessage({ + defaultMessage: 'Select all', + id: '5GHXCP', + description: 'Label for select all checkbox', + }), + selectRow: intl.formatMessage({ + defaultMessage: 'Select row', + id: '/BY2cI', + description: 'Label for select row checkbox', + }), + inProgressStatus: intl.formatMessage({ + defaultMessage: 'In progress', + id: 'gyfZhJ', + description: 'Text to indicate that the artifact upload is in progress', + }), + completedStatus: intl.formatMessage({ + defaultMessage: 'Complete', + id: '9euy52', + description: 'Text to indicate that the artifact upload is completed', + }), + failedStatus: intl.formatMessage({ + defaultMessage: 'Error', + id: 'fs92Nu', + description: 'Text to indicate that the artifact upload has failed', + }), + collapseHub: intl.formatMessage({ + defaultMessage: 'Collapse hub', + id: 'UXbZTn', + description: 'Aria label for collapse hub button', + }), + expandHub: intl.formatMessage({ + defaultMessage: 'Expand hub', + id: 'hfz4Il', + description: 'Aria label for expand hub button', + }), + }; + + const [allItems, setAllItems] = useState>(createAllItems(hubs, /* existingItems: */ {})); + + useEffect(() => { + if (hubs) { + setAllItems((prev) => createAllItems(hubs, prev)); + } + }, [hubs]); + + const columns = [ + createTableColumn({ + columnId: 'name', + }), + createTableColumn({ + columnId: 'type', + }), + createTableColumn({ + columnId: 'description', + }), + createTableColumn({ + columnId: 'createdDate', + }), + createTableColumn({ + columnId: 'status', + }), + createTableColumn({ + columnId: 'actions', + }), + ]; + + // Getting the viewable items based on the expanded/collapsed state of the hubs + const items = useMemo( + () => + Object.values(allItems).reduce((acc: KnowledgeHubItem[], item: KnowledgeHubItem) => { + if (item.parentId === null) { + acc.push(item); + } + + if (item.parentId === null && item.isExpanded) { + const childItems = (hubs.find((hub) => hub.name === item.name)?.artifacts ?? []).map( + (artifact) => `${item.name.toLowerCase()}-${artifact.name.toLowerCase()}` + ); + acc.push(...childItems.map((child) => allItems[child])); + } + + return acc; + }, []), + [hubs, allItems] + ); + + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [artifactToDelete, setArtifactToDelete] = useState(null); + const [selectedItems, setSelectedItems] = useState([]); + const setSelectedArtifactItems = useCallback( + (artifacts: string[]) => { + setSelectedItems(artifacts); + setSelectedArtifacts(artifacts.map((artifactName) => getPropertyValue(allItems, artifactName))); + }, + [allItems, setSelectedArtifacts] + ); + + const { getRows } = useTableFeatures({ columns, items }, [useTableSelection({ selectionMode: 'multiselect' })]); + + const isRowSelected = useCallback((id: string) => !!selectedItems.find((item) => equals(item, id)), [selectedItems]); + const allRowsSelected = useMemo(() => items.every((item) => isRowSelected(item.id)), [items, isRowSelected]); + const someRowsSelected = useMemo(() => items.some((item) => isRowSelected(item.id)), [items, isRowSelected]); + + const toggleRowItem = useCallback( + (e: React.MouseEvent | React.KeyboardEvent, item: KnowledgeHubItem) => { + const selected = isRowSelected(item.id); + const isHubGroup = item.parentId === null; + const artifactsInHub = isHubGroup ? getArtifactItemsInHub(item, allItems) : []; + const artifactsToToggleState: string[] = []; + + if (isHubGroup) { + if (selected) { + // If the hub group is already selected, unselect the hub group and all its artifacts + for (const artifact of artifactsInHub) { + if (isRowSelected(artifact.id)) { + artifactsToToggleState.push(artifact.id); + } + } + } else { + // If the hub group is not selected, select the hub group and all its artifacts + for (const artifact of artifactsInHub) { + if (!isRowSelected(artifact.id)) { + artifactsToToggleState.push(artifact.id); + } + } + } + } else { + const hubItem = getPropertyValue(allItems, item.parentId as string); + if (hubItem) { + const artifactsInSameHub = getArtifactItemsInHub(hubItem, allItems); + const selectedArtifactsInSameHub = artifactsInSameHub.filter((artifact) => isRowSelected(artifact.id)).length; + + if (!selected && selectedArtifactsInSameHub === artifactsInSameHub.length - 1 && !isRowSelected(hubItem.id)) { + // If the artifact is not selected and it's the last unselected artifact in the hub, select the hub group + artifactsToToggleState.push(hubItem.id); + } else if (selected && selectedArtifactsInSameHub === artifactsInSameHub.length && isRowSelected(hubItem.id)) { + // If the artifact is selected and it's the only selected artifact in the hub, unselect the hub group + artifactsToToggleState.push(hubItem.id); + } + } + } + + artifactsToToggleState.push(item.id); + + let finalSelectedItems: string[] = selectedItems.slice(); + for (const id of artifactsToToggleState) { + if (selectedItems.includes(id)) { + finalSelectedItems = finalSelectedItems.filter((i) => !equals(i, id)); + } else { + finalSelectedItems.push(id); + } + } + + setSelectedArtifactItems(finalSelectedItems); + }, + [allItems, isRowSelected, selectedItems, setSelectedArtifactItems] + ); + const rows = getRows((row) => { + const selected = isRowSelected(row.item.id); + return { + ...row, + onClick: (e: React.MouseEvent) => toggleRowItem(e, row.item), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === ' ') { + e.preventDefault(); + toggleRowItem(e, row.item); + } + }, + selected, + appearance: selected ? ('brand' as const) : ('none' as const), + }; + }); + + const toggleAllRows = useCallback(() => { + setSelectedArtifactItems(allRowsSelected ? [] : Object.values(allItems).map((item) => item.id)); + }, [setSelectedArtifactItems, allItems, allRowsSelected]); + + const toggleAllKeydown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === ' ') { + toggleAllRows(); + e.preventDefault(); + } + }, + [toggleAllRows] + ); + + const handleExpandCollapse = useCallback((item: KnowledgeHubItem, event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + setAllItems((prev) => ({ + ...prev, + [item.id]: { + ...prev[item.id], + isExpanded: !prev[item.id].isExpanded, + }, + })); + }, []); + + const handleContextMenuClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + }, []); + + const handleDelete = useCallback(async (item: KnowledgeHubItem, event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + setArtifactToDelete(item); + setShowDeleteModal(true); + }, []); + const handleCloseDeleteModal = useCallback(() => setShowDeleteModal(false), []); + const handleOnDeleteComplete = useCallback(() => { + const itemDeleted = artifactToDelete; + setShowDeleteModal(false); + + if (!itemDeleted) { + return; + } + + if (selectedItems.includes(itemDeleted.id)) { + setSelectedArtifactItems(selectedItems.filter((id) => !equals(id, itemDeleted.id))); + } + setArtifactToDelete(null); + queryClient.setQueryData(['knowledgehubs', resourceId.toLowerCase()], (oldData: KnowledgeHub[] | undefined) => { + if (oldData) { + if (itemDeleted.parentId === null) { + // Hub group deleted, remove the whole hub + return oldData.filter((hub) => !equals(hub.name, itemDeleted.name)); + } + // Artifact deleted, remove the artifact from the hub + return oldData.map((hub) => { + if (equals(hub.name, itemDeleted.parentId)) { + return { + ...hub, + artifacts: hub.artifacts.filter((artifact) => !equals(artifact.name, itemDeleted.name)), + }; + } + return hub; + }); + } + + return oldData; + }); + }, [artifactToDelete, queryClient, resourceId, selectedItems, setSelectedArtifactItems]); + + const handleUploadArtifacts = useCallback( + (item: KnowledgeHubItem, event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + onUploadArtifacts(hubs.find((hub) => hub.name === item.name)!); + }, + [hubs, onUploadArtifacts] + ); + + const renderNameCell = useCallback( + (item: KnowledgeHubItem) => { + const isHubGroup = item.parentId === null; + const className = isHubGroup ? undefined : styles.artifactNameCell; + + return ( + +
+ {isHubGroup ? ( +
+
+ ); + }, + [ + INTL_TEXT.collapseHub, + INTL_TEXT.expandHub, + handleExpandCollapse, + styles.artifactNameCell, + styles.nameCell, + styles.nameText, + styles.rowCell, + ] + ); + + const renderTextCell = useCallback( + (text: string) => ( + + + {text} + + + ), + [styles.rowCell] + ); + + const renderStatusCell = useCallback( + (status: ArtifactCreationStatus) => { + let icon: React.ReactNode | null = null; + let text: string; + + switch (status) { + case ArtifactCreationStatus.InProgress: { + icon = ; + text = INTL_TEXT.inProgressStatus; + break; + } + case ArtifactCreationStatus.Completed: { + icon = ; + text = INTL_TEXT.completedStatus; + break; + } + case ArtifactCreationStatus.Failed: { + icon = ; + text = INTL_TEXT.failedStatus; + break; + } + default: + text = status; + } + + return ( + +
+ {icon} + + {text} + +
+
+ ); + }, + [INTL_TEXT.completedStatus, INTL_TEXT.failedStatus, INTL_TEXT.inProgressStatus, styles.rowCell, styles.statusCell] + ); + + const getSelectionStateForItem = useCallback( + (item: KnowledgeHubItem, selected: boolean): boolean | 'mixed' => { + if (item.parentId === null) { + if (selected) { + return true; + } + + // Hub group + const artifactsInHub = getArtifactItemsInHub(item, allItems) ?? []; + const selectedArtifactsInHub = artifactsInHub.filter((artifact) => isRowSelected(artifact.id)).length; + + if (selectedArtifactsInHub === 0) { + return selected; + } + + if (selectedArtifactsInHub === artifactsInHub.length) { + return true; + } + + return 'mixed'; + } + + // Artifact + const hubItem = getPropertyValue(allItems, item.parentId); + return hubItem && isRowSelected(hubItem.id) ? true : selected; + }, + [allItems, isRowSelected] + ); + + useEffect(() => { + // Check if any item has been deleted to update the selected list + if (allItems && selectedItems) { + const deletedItems = selectedItems.filter((id) => !getPropertyValue(allItems, id)); + if (deletedItems.length) { + const newSelectedItems = selectedItems.filter((id) => !deletedItems.includes(id)); + setSelectedArtifactItems(newSelectedItems); + } + } + }, [allItems, selectedItems, setSelectedArtifactItems]); + + if (!items.length) { + return null; + } + + return ( +
+ + + + + {INTL_TEXT.nameLabel} + + {INTL_TEXT.typeLabel} + + + {INTL_TEXT.descriptionLabel} + + {INTL_TEXT.createdDateLabel} + {INTL_TEXT.statusLabel} + {/* Actions column, no header */} + + + + {rows.map(({ item, selected, onClick, onKeyDown, appearance }) => ( + + + {renderNameCell(item)} + {renderTextCell(item.type)} + {renderTextCell(item.description)} + {renderTextCell(item.createdDate)} + {renderStatusCell(item.status)} + + + + + + + ))} + +
+ {showDeleteModal && artifactToDelete ? ( + + ) : null} +
+ ); +}; + +const createAllItems = (hubs: KnowledgeHub[], existingItems: Record): Record => + hubs.reduce( + (result: Record, hub: KnowledgeHub) => { + const id = hub.name.toLowerCase(); + result[id] = { + id, + name: hub.name, + type: 'folder', + description: hub.description, + createdDate: hub.createdAt ? new Date(hub.createdAt).toLocaleString() : '--', + status: '--', // Need to determine how to get upload status + parentId: null, + isExpanded: existingItems[id]?.isExpanded ?? false, // Preserve the expanded state if the item already exists + }; + // biome-ignore lint/performance/noAccumulatingSpread: + return { ...result, ...createArtifactItems(hub, existingItems) }; + }, + {} as Record + ); + +const createArtifactItems = (hub: KnowledgeHub, existingItems: Record): Record => + hub.artifacts.reduce( + (result: Record, artifact) => { + const id = `${hub.name.toLowerCase()}-${artifact.name.toLowerCase()}`; + result[id] = { + id, + name: artifact.name, + type: 'file', + description: artifact.description, + createdDate: artifact.createdAt ? new Date(artifact.createdAt).toLocaleString() : '--', + status: artifact.uploadStatus, // Need to determine how to get upload status + parentId: hub.name, + isExpanded: existingItems[id]?.isExpanded ?? false, // Preserve the expanded state if the item already exists + }; + return result; + }, + {} as Record + ); + +const getArtifactItemsInHub = (hub: KnowledgeHubItem, allItems: Record): KnowledgeHubItem[] => + Object.values(allItems ?? []).filter((item) => equals(item.parentId, hub.name)) ?? []; diff --git a/libs/designer/src/lib/ui/knowledge/wizard/styles.ts b/libs/designer/src/lib/ui/knowledge/wizard/styles.ts index 41ee6dd69a1..c66b8a6f8aa 100644 --- a/libs/designer/src/lib/ui/knowledge/wizard/styles.ts +++ b/libs/designer/src/lib/ui/knowledge/wizard/styles.ts @@ -41,3 +41,50 @@ export const useWizardStyles = makeStyles({ padding: '10px 0', }, }); + +export const useListStyles = makeStyles({ + tableStyle: { + width: '95%', + }, + + tableCell: { + border: 'none', + paddingBottom: '8px', + }, + + rowCell: { + alignItems: 'center', + }, + + icon: { + marginRight: '8px', + }, + + iconsCell: { + textAlign: 'right', + border: 'none', + paddingBottom: '8px', + width: '8%', + }, + + nameCell: { + display: 'flex', + }, + + nameText: { + display: 'flex', + gap: '8px', + marginTop: '6px', + }, + + hubNameCell: {}, + + artifactNameCell: { + marginLeft: '32px', + }, + + statusCell: { + display: 'flex', + gap: '4px', + }, +}); diff --git a/libs/logic-apps-shared/src/utils/src/lib/models/knowledge.ts b/libs/logic-apps-shared/src/utils/src/lib/models/knowledge.ts index 8e5339f131b..9ec8dfaee05 100644 --- a/libs/logic-apps-shared/src/utils/src/lib/models/knowledge.ts +++ b/libs/logic-apps-shared/src/utils/src/lib/models/knowledge.ts @@ -1,20 +1,25 @@ export interface KnowledgeHub { + id: string; name: string; description: string; + partitionKey: string; + createdAt: string; } export interface KnowledgeHubArtifact { + id: string; name: string; description: string; - type?: ArtifactType; - contentKind?: ContentKind; - contentStream?: any; - creationStatus?: ArtifactCreationStatus; + knowledgeHubId: string; + artifactSource: ArtifactType; + uploadStatus: ArtifactCreationStatus; + partitionKey: string; + createdAt: string; } export const ArtifactCreationStatus = { Initialized: 'Initialized', - Processing: 'Processing', + InProgress: 'InProgress', Completed: 'Completed', Failed: 'Failed', };