From 3c2bc6caf3ad8a4071dae1f6e5ae821a26d5e30c Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 19 Dec 2025 23:44:21 +0600 Subject: [PATCH] feat(common): create and manage example responses in collections (#5652) Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com> --- packages/hoppscotch-common/locales/en.json | 5 +- .../components/collections/MyCollections.vue | 16 ++ .../src/components/collections/Request.vue | 17 ++ .../collections/TeamCollections.vue | 16 ++ .../src/components/collections/index.vue | 269 +++++++++++++++++- .../src/helpers/utils/EffectiveURL.ts | 9 +- 6 files changed, 328 insertions(+), 4 deletions(-) diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 0ea00365..e8b72d83 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -54,6 +54,8 @@ "retry": "Retry", "save": "Save", "save_as_example": "Save as example", + "add_example": "Add example", + "invalid_request": "Invalid request data", "scroll_to_bottom": "Scroll to bottom", "scroll_to_top": "Scroll to top", "search": "Search", @@ -724,7 +726,8 @@ "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", "generate_access_token": "Something went wrong while generating the access token", "delete_access_token": "Something went wrong while deleting the access token", - "extension_not_found": "Extension not found" + "extension_not_found": "Extension not found", + "invalid_request": "Invalid request data" }, "export": { "as_json": "Export as JSON", diff --git a/packages/hoppscotch-common/src/components/collections/MyCollections.vue b/packages/hoppscotch-common/src/components/collections/MyCollections.vue index 09dd2424..dbcd055d 100644 --- a/packages/hoppscotch-common/src/components/collections/MyCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/MyCollections.vue @@ -352,6 +352,14 @@ request: node.data.data.data, }) " + @add-example=" + node.data.type === 'requests' && + emit('add-example', { + folderPath: node.data.data.parentIndex, + request: node.data.data.data, + requestIndex: pathToIndex(node.id), + }) + " @drag-request=" dragRequest($event, { folderPath: node.data.data.parentIndex, @@ -659,6 +667,14 @@ const emit = defineEmits<{ request: HoppRESTRequest } ): void + ( + event: "add-example", + payload: { + folderPath: string + request: HoppRESTRequest + requestIndex: number + } + ): void ( event: "drop-request", payload: { diff --git a/packages/hoppscotch-common/src/components/collections/Request.vue b/packages/hoppscotch-common/src/components/collections/Request.vue index 628bd5d6..1b615dc9 100644 --- a/packages/hoppscotch-common/src/components/collections/Request.vue +++ b/packages/hoppscotch-common/src/components/collections/Request.vue @@ -103,6 +103,7 @@ @keyup.delete="deleteAction?.$el.click()" @keyup.s="shareAction?.$el.click()" @keyup.i="documentationAction?.$el.click()" + @keyup.a="addExampleAction?.$el.click()" @keyup.escape="hide()" > + (null) const duplicate = ref(null) const shareAction = ref(null) const documentationAction = ref(null) +const addExampleAction = ref(null) const { isDocumentationVisible } = useDocumentationVisibility() diff --git a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue index 12c2efe3..bd74b679 100644 --- a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue @@ -398,6 +398,14 @@ request: node.data.data.data.request, }) " + @add-example=" + node.data.type === 'requests' && + emit('add-example', { + folderPath: getPath(node.id), + request: node.data.data.data.request, + requestIndex: node.data.data.data.id, + }) + " @drag-request=" dragRequest($event, { folderPath: node.data.data.parentIndex, @@ -705,6 +713,14 @@ const emit = defineEmits<{ request: HoppRESTRequest } ): void + ( + event: "add-example", + payload: { + folderPath: string + request: HoppRESTRequest + requestIndex: string + } + ): void ( event: "sort-collections", payload: { diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue index 593e8d77..6a25a51f 100644 --- a/packages/hoppscotch-common/src/components/collections/index.vue +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -65,6 +65,7 @@ @remove-request="removeRequest" @remove-response="removeResponse" @share-request="shareRequest" + @add-example="addExample" @select="selectPicked" @select-response="selectResponse" @select-request="selectRequest" @@ -116,6 +117,7 @@ @remove-folder="removeFolder" @remove-request="removeRequest" @remove-response="removeResponse" + @add-example="addExample" @run-collection=" runCollectionHandler({ type: 'team-collections', @@ -187,6 +189,42 @@ @submit="updateEditingResponse" @hide-modal="displayModalEditResponse(false)" /> + + + + (null) const editingRequestID = ref(null) const editingResponseID = ref(null) +const showAddExampleModal = ref(false) const editingProperties = ref({ collection: null, @@ -838,6 +880,12 @@ const displayModalDocumentation = (show: boolean) => { if (!show) resetSelectedData() } +const displayModalAddExample = (show: boolean) => { + showAddExampleModal.value = show + + if (!show) resetSelectedData() +} + const addNewRootCollection = async (name: string) => { if (collectionsType.value.type === "my-collections") { modalLoadingState.value = true @@ -1644,6 +1692,225 @@ const duplicateResponse = async (payload: ResponseConfigPayload) => { } } +const addExample = (payload: { + folderPath: string + request: HoppRESTRequest + requestIndex: string | number +}) => { + const { folderPath, request, requestIndex } = payload + + // Defensive check to ensure request is valid + if (!request || typeof request !== "object") { + console.error("Invalid request object:", request) + toast.error(t("error.invalid_request")) + return + } + + // Additional validation for required request properties + if (!request.name && !request.endpoint) { + console.error("Request missing required properties:", request) + toast.error(t("error.invalid_request")) + return + } + + editingRequest.value = request + editingRequestName.value = request.name ?? "" + editingResponseName.value = "" + editingResponseOldName.value = "" + + if (collectionsType.value.type === "my-collections" && folderPath) { + editingFolderPath.value = folderPath + editingRequestIndex.value = parseInt(requestIndex.toString()) + } else { + editingRequestID.value = requestIndex.toString() + } + displayModalAddExample(true) +} + +const onAddExample = async () => { + const exampleName = editingResponseName.value.trim() + + if (!exampleName) { + toast.error(t("response.invalid_name")) + return + } + + const request = editingRequest.value + if (!request || !request.name) { + toast.error(t("error.invalid_request")) + return + } + + // Check if example name already exists + if (request.responses && request.responses[exampleName]) { + toast.error(t("response.duplicate_name_error")) + return + } + + // Create the original request from the parent request + const originalRequest = makeHoppRESTResponseOriginalRequest({ + name: request.name, + method: request.method, + endpoint: request.endpoint, + headers: request.headers, + params: request.params, + body: request.body, + auth: request.auth, + requestVariables: request.requestVariables, + }) + + // Create a new example response with default values and original request + const newExample: HoppRESTRequestResponse = { + name: exampleName, + code: 200, + status: "OK", + headers: [], + body: "", + originalRequest, + } + + // Calculate the new example's index (will be used as exampleID) + const existingResponsesCount = request.responses + ? Object.keys(request.responses).length + : 0 + const newExampleID = existingResponsesCount.toString() + + const updatedRequest = { + ...request, + responses: { + ...request.responses, + [exampleName]: newExample, + }, + } + + if (collectionsType.value.type === "my-collections") { + const folderPath = editingFolderPath.value + const requestIndex = editingRequestIndex.value + + if (folderPath === null || requestIndex === null) return + + const isValidToken = await handleTokenValidation() + if (!isValidToken) return + + editRESTRequest(folderPath, requestIndex, updatedRequest) + toast.success(t("response.saved")) + + const possibleRequestActiveTab = tabs.getTabRefWithSaveContext({ + originLocation: "user-collection", + requestIndex, + folderPath, + }) + + // Update request tab responses if it's open + if ( + possibleRequestActiveTab && + possibleRequestActiveTab.value.document.type === "request" + ) { + possibleRequestActiveTab.value.document.request.responses = + updatedRequest.responses + } + + // Close the modal first + displayModalAddExample(false) + + // Open the new example in a new tab + tabs.createNewTab({ + response: { + ...cloneDeep(newExample), + name: exampleName, + }, + isDirty: false, + type: "example-response", + saveContext: { + originLocation: "user-collection", + folderPath: folderPath, + requestIndex: requestIndex, + exampleID: newExampleID, + }, + inheritedProperties: cascadeParentCollectionForProperties( + folderPath, + "rest" + ), + }) + } else if (hasTeamWriteAccess.value) { + modalLoadingState.value = true + + if (!editingRequestID.value) return + + // Double-check request is still valid before proceeding + if (!request || !request.name) { + toast.error(t("error.invalid_request")) + modalLoadingState.value = false + return + } + + const data = { + requestID: editingRequestID.value, + data: { + title: request.name, + request: JSON.stringify(updatedRequest), + }, + } + + pipe( + runMutation(UpdateRequestDocument, data), + TE.match( + (err: GQLError) => { + toast.error(`${getErrorMessage(err)}`) + modalLoadingState.value = false + }, + () => { + modalLoadingState.value = false + toast.success(t("response.saved")) + displayModalAddExample(false) + + const requestID = editingRequestID.value + const collectionID = editingFolderPath.value + + if (!requestID) return + + // Update the request tab responses if it's open + const possibleRequestActiveTab = tabs.getTabRefWithSaveContext({ + originLocation: "team-collection", + requestID: requestID, + }) + + if ( + possibleRequestActiveTab && + possibleRequestActiveTab.value.document.type === "request" + ) { + possibleRequestActiveTab.value.document.request.responses = + updatedRequest.responses + } + + // Open the new example in a new tab + tabs.createNewTab({ + response: { + ...cloneDeep(newExample), + name: exampleName, + }, + isDirty: false, + type: "example-response", + saveContext: { + originLocation: "team-collection", + requestID: requestID, + collectionID: collectionID ?? undefined, + exampleID: newExampleID, + }, + inheritedProperties: collectionID + ? teamCollectionService.cascadeParentCollectionForProperties( + collectionID + ) + : undefined, + }) + } + ) + )() + + return + } +} + const removeCollection = (id: string) => { if (collectionsType.value.type === "my-collections") editingCollectionIndex.value = parseInt(id) diff --git a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts index 83545c46..56fd217d 100644 --- a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts +++ b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts @@ -235,8 +235,13 @@ export const resolvesEnvsInBody = ( let bodyContent = "" + // WORKAROUND: body.body can be null when creating example responses programmatically + // (see PR #5652), despite the Zod schema requiring string with .catch(""). The ?? "" + // fallback guards against stripComments() crash when passed null/empty, since the + // underlying stripComments_("") from jsonc-parser returns null. This pattern is used + // consistently throughout this file (see also getFinalBodyFromRequest). if (isJSONContentType(body.contentType)) - bodyContent = stripComments(body.body) + bodyContent = stripComments(body.body ?? "") if (body.contentType === "application/x-www-form-urlencoded") { bodyContent = body.body @@ -338,7 +343,7 @@ export function getFinalBodyFromRequest( let bodyContent = request.body.body ?? "" if (isJSONContentType(request.body.contentType)) - bodyContent = stripComments(request.body.body) + bodyContent = stripComments(request.body.body ?? "") // body can be null if the content-type is not set return parseBodyEnvVariables(bodyContent, envVariables)