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()"
>
+ {
+ emit('add-example')
+ 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)