feat(common): create and manage example responses in collections (#5652)

Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Anwarul Islam 2025-12-19 23:44:21 +06:00 committed by GitHub
parent d91c554fca
commit 3c2bc6caf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 328 additions and 4 deletions

View file

@ -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",

View file

@ -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: {

View file

@ -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()"
>
<HoppSmartItem
@ -131,6 +132,19 @@
}
"
/>
<HoppSmartItem
v-if="!hasNoTeamAccess"
ref="addExampleAction"
:icon="IconPlusCircle"
:label="t('action.add_example')"
:shortcut="['A']"
@click="
() => {
emit('add-example')
hide()
}
"
/>
<HoppSmartItem
v-if="isDocumentationVisible"
ref="documentationAction"
@ -230,6 +244,7 @@ import IconShare2 from "~icons/lucide/share-2"
import IconArrowRight from "~icons/lucide/chevron-right"
import IconArrowDown from "~icons/lucide/chevron-down"
import IconBook from "~icons/lucide/book"
import IconPlusCircle from "~icons/lucide/plus-circle"
import { ref, PropType, watch, computed } from "vue"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
@ -319,6 +334,7 @@ const emit = defineEmits<{
(event: "remove-request"): void
(event: "select-request"): void
(event: "share-request"): void
(event: "add-example"): void
(event: "drag-request", payload: DataTransfer): void
(event: "update-request-order", payload: DataTransfer): void
(event: "update-last-request-order", payload: DataTransfer): void
@ -335,6 +351,7 @@ const options = ref<TippyComponent | null>(null)
const duplicate = ref<HTMLButtonElement | null>(null)
const shareAction = ref<HTMLButtonElement | null>(null)
const documentationAction = ref<HTMLButtonElement | null>(null)
const addExampleAction = ref<HTMLButtonElement | null>(null)
const { isDocumentationVisible } = useDocumentationVisibility()

View file

@ -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: {

View file

@ -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)"
/>
<HoppSmartModal
v-if="showAddExampleModal"
dialog
:title="t('action.add_example')"
@close="displayModalAddExample(false)"
>
<template #body>
<div class="flex gap-1">
<HoppSmartInput
v-model="editingResponseName"
class="flex-grow"
placeholder=" "
:label="t('action.label')"
input-styles="floating-input !border-0"
styles="border border-divider rounded"
@submit="onAddExample"
/>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
:label="t('action.add')"
:loading="modalLoadingState"
outline
@click="onAddExample"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="displayModalAddExample(false)"
/>
</span>
</template>
</HoppSmartModal>
<HoppSmartConfirmModal
:show="showConfirmModal"
:title="confirmModalTitle"
@ -262,7 +300,9 @@ import {
HoppRESTAuth,
HoppRESTHeaders,
HoppRESTRequest,
HoppRESTRequestResponse,
makeCollection,
makeHoppRESTResponseOriginalRequest,
} from "@hoppscotch/data"
import { useService } from "dioc/vue"
import { MODULE_PREFIX_REGEX_JSON_SERIALIZED } from "~/helpers/scripting"
@ -277,7 +317,8 @@ import { cloneDeep, debounce, isEqual } from "lodash-es"
import { PropType, computed, nextTick, onMounted, ref, watch } from "vue"
import { useReadonlyStream } from "~/composables/stream"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { GQLError } from "~/helpers/backend/GQLClient"
import { GQLError, runMutation } from "~/helpers/backend/GQLClient"
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
import {
CollectionDataProps,
getCompleteCollectionTree,
@ -414,6 +455,7 @@ const editingRequestIndex = ref<number | null>(null)
const editingRequestID = ref<string | null>(null)
const editingResponseID = ref<string | null>(null)
const showAddExampleModal = ref(false)
const editingProperties = ref<EditingProperties>({
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<string>) => {
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)

View file

@ -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)