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:
parent
d91c554fca
commit
3c2bc6caf3
6 changed files with 328 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue