feat: add bulk edit mode support in multipart/form-data (#4630)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
6fd93b9f5d
commit
0ae21e2c2e
6 changed files with 271 additions and 72 deletions
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
class="sticky top-upperMobileStickyFold z-10 flex flex-shrink-0 items-center justify-between overflow-x-auto border-b border-dividerLight bg-primary pl-4 sm:top-upperMobileTertiaryStickyFold"
|
||||
>
|
||||
|
|
@ -31,16 +31,35 @@
|
|||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="isBulkEditing"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': wrapLines }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="
|
||||
toggleNestedSetting('WRAP_LINES', 'multipartFormdata')
|
||||
"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.bulk_mode')"
|
||||
:class="{ '!text-accent': isBulkEditing }"
|
||||
:icon="IconBulkEdit"
|
||||
@click="toggleBulkEdit"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('add.new')"
|
||||
:icon="IconPlus"
|
||||
:disabled="isBulkEditing"
|
||||
@click="addBodyParam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-if="!isBulkEditing"
|
||||
v-model="workingParams"
|
||||
item-key="id"
|
||||
animation="250"
|
||||
|
|
@ -190,6 +209,10 @@
|
|||
</template>
|
||||
</draggable>
|
||||
|
||||
<div v-else-if="isBulkEditing" class="h-full relative flex flex-col flex-1">
|
||||
<div ref="bulkEditor" class="absolute inset-0"></div>
|
||||
</div>
|
||||
|
||||
<HoppSmartPlaceholder
|
||||
v-if="workingParams.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/upload_single_file.svg`"
|
||||
|
|
@ -216,11 +239,17 @@ import IconGripVertical from "~icons/lucide/grip-vertical"
|
|||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import { ref, watch } from "vue"
|
||||
import IconBulkEdit from "~icons/lucide/edit"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as A from "fp-ts/Array"
|
||||
import { FormDataKeyValue, HoppRESTReqBody } from "@hoppscotch/data"
|
||||
import {
|
||||
FormDataKeyValue,
|
||||
HoppRESTReqBody,
|
||||
parseRawKeyValueEntriesE,
|
||||
} from "@hoppscotch/data"
|
||||
import { isEqual, clone } from "lodash-es"
|
||||
import draggable from "vuedraggable-es"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
|
|
@ -229,6 +258,11 @@ import { useToast } from "@composables/toast"
|
|||
import { useColorMode } from "@composables/theming"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { AggregateEnvironment } from "~/newstore/environments"
|
||||
import { useCodemirror } from "~/composables/codemirror"
|
||||
import { useNestedSetting } from "~/composables/settings"
|
||||
import { toggleNestedSetting } from "~/newstore/settings"
|
||||
import * as E from "fp-ts/Either"
|
||||
import linter from "~/helpers/editor/linting/rawKeyValue"
|
||||
|
||||
type Body = HoppRESTReqBody & { contentType: "multipart/form-data" }
|
||||
|
||||
|
|
@ -443,6 +477,90 @@ const deleteBodyParam = (index: number) => {
|
|||
)
|
||||
}
|
||||
|
||||
const convertWorkingParamsToBulkEditContent = (
|
||||
params: WorkingFormDataKeyValue[]
|
||||
) => {
|
||||
return (
|
||||
params
|
||||
.filter((param) => param.entry.key !== "")
|
||||
// filter out file params
|
||||
.filter((param) => !param.entry.isFile)
|
||||
.map(
|
||||
(param) =>
|
||||
`${!param.entry.active ? "#" : ""}${param.entry.key}: ${param.entry.value}`
|
||||
)
|
||||
.join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
const bulkEditor = ref<HTMLElement | null>(null)
|
||||
const bulkEditContent = ref<string | undefined>(
|
||||
convertWorkingParamsToBulkEditContent(
|
||||
Array.isArray(bodyParams.value)
|
||||
? bodyParams.value.map((entry) => ({ id: idTicker.value++, entry }))
|
||||
: []
|
||||
)
|
||||
)
|
||||
const isBulkEditing = ref(body.value.isBulkEditing)
|
||||
const wrapLines = useNestedSetting("WRAP_LINES", "multipartFormdata")
|
||||
|
||||
watch(isBulkEditing, () => {
|
||||
body.value.isBulkEditing = isBulkEditing.value
|
||||
})
|
||||
|
||||
// update working params when bulk edit content changes
|
||||
watch(bulkEditContent, () => {
|
||||
if (isBulkEditing.value && bulkEditContent.value !== undefined) {
|
||||
const res = parseRawKeyValueEntriesE(bulkEditContent.value)
|
||||
|
||||
if (E.isLeft(res)) {
|
||||
return
|
||||
}
|
||||
|
||||
workingParams.value = [
|
||||
...res.right.map((entry) => ({
|
||||
id: idTicker.value++,
|
||||
entry: {
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
active: entry.active,
|
||||
isFile: false as const,
|
||||
},
|
||||
})),
|
||||
// file params are not supported in bulk edit, so we need to add them back
|
||||
...workingParams.value.filter((param) => param.entry.isFile),
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const toggleBulkEdit = () => {
|
||||
isBulkEditing.value = !isBulkEditing.value
|
||||
|
||||
if (isBulkEditing.value) {
|
||||
bulkEditContent.value = convertWorkingParamsToBulkEditContent(
|
||||
workingParams.value
|
||||
)
|
||||
} else {
|
||||
bulkEditContent.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
useCodemirror(
|
||||
bulkEditor,
|
||||
bulkEditContent,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "text/x-yaml",
|
||||
placeholder: t("state.bulk_mode_placeholder").toString(),
|
||||
lineWrapping: wrapLines,
|
||||
},
|
||||
linter,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
predefinedVariablesHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
const clearContent = () => {
|
||||
// set params list to the initial state
|
||||
workingParams.value = [
|
||||
|
|
@ -456,6 +574,10 @@ const clearContent = () => {
|
|||
},
|
||||
},
|
||||
]
|
||||
|
||||
// clear bulk edit content
|
||||
bulkEditContent.value = ""
|
||||
isBulkEditing.value = false
|
||||
}
|
||||
|
||||
const setRequestAttachment = (
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@
|
|||
@click="clearContent()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="bulkUrlEncodedParams"
|
||||
v-if="isBulkEditing"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': WRAP_LINES }"
|
||||
:class="{ '!text-accent': wrapLines }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpUrlEncoded')"
|
||||
/>
|
||||
|
|
@ -32,19 +32,22 @@
|
|||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.bulk_mode')"
|
||||
:icon="IconEdit"
|
||||
:class="{ '!text-accent': bulkMode }"
|
||||
@click="bulkMode = !bulkMode"
|
||||
:class="{ '!text-accent': isBulkEditing }"
|
||||
@click="toggleBulkEdit"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('add.new')"
|
||||
:icon="IconPlus"
|
||||
:disabled="bulkMode"
|
||||
:disabled="isBulkEditing"
|
||||
@click="addUrlEncodedParam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bulkMode" class="h-full relative w-full flex flex-col flex-1">
|
||||
<div
|
||||
v-if="isBulkEditing"
|
||||
class="h-full relative w-full flex flex-col flex-1"
|
||||
>
|
||||
<div ref="bulkEditor" class="absolute inset-0"></div>
|
||||
</div>
|
||||
<div v-else>
|
||||
|
|
@ -190,7 +193,6 @@ import {
|
|||
import { flow, pipe } from "fp-ts/function"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import * as E from "fp-ts/Either"
|
||||
import draggable from "vuedraggable-es"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
|
|
@ -227,29 +229,8 @@ const colorMode = useColorMode()
|
|||
|
||||
const idTicker = ref(0)
|
||||
|
||||
const bulkMode = ref(false)
|
||||
const bulkUrlEncodedParams = ref("")
|
||||
const bulkEditor = ref<any | null>(null)
|
||||
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpUrlEncoded")
|
||||
|
||||
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
|
||||
|
||||
useCodemirror(
|
||||
bulkEditor,
|
||||
bulkUrlEncodedParams,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "text/x-yaml",
|
||||
placeholder: `${t("state.bulk_mode_placeholder")}`,
|
||||
lineWrapping: WRAP_LINES,
|
||||
},
|
||||
linter,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
predefinedVariablesHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
// The functional urlEncodedParams list (the urlEncodedParams actually in the system)
|
||||
const urlEncodedParamsRaw = pluckRef(body, "body")
|
||||
|
||||
|
|
@ -303,28 +284,12 @@ watch(
|
|||
)
|
||||
)
|
||||
|
||||
const filteredBulkUrlEncodedParams = pipe(
|
||||
parseRawKeyValueEntriesE(bulkUrlEncodedParams.value),
|
||||
E.map(
|
||||
flow(
|
||||
RA.filter((e) => e.key !== ""),
|
||||
RA.toArray
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!isEqual(newurlEncodedParamList, filteredWorkingUrlEncodedParams)) {
|
||||
workingUrlEncodedParams.value = pipe(
|
||||
newurlEncodedParamList,
|
||||
A.map((x) => ({ id: idTicker.value++, ...x }))
|
||||
)
|
||||
}
|
||||
|
||||
if (!isEqual(newurlEncodedParamList, filteredBulkUrlEncodedParams)) {
|
||||
bulkUrlEncodedParams.value = rawKeyValueEntriesToString(
|
||||
newurlEncodedParamList
|
||||
)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
|
@ -345,23 +310,6 @@ watch(workingUrlEncodedParams, (newWorkingUrlEncodedParams) => {
|
|||
}
|
||||
})
|
||||
|
||||
watch(bulkUrlEncodedParams, (newBulkUrlEncodedParams) => {
|
||||
const filteredBulkParams = pipe(
|
||||
parseRawKeyValueEntriesE(newBulkUrlEncodedParams),
|
||||
E.map(
|
||||
flow(
|
||||
RA.filter((e) => e.key !== ""),
|
||||
RA.toArray
|
||||
)
|
||||
),
|
||||
E.getOrElse(() => [] as RawKeyValueEntry[])
|
||||
)
|
||||
|
||||
if (!isEqual(urlEncodedParams.value, filteredBulkParams)) {
|
||||
urlEncodedParams.value = filteredBulkParams
|
||||
}
|
||||
})
|
||||
|
||||
const addUrlEncodedParam = () => {
|
||||
workingUrlEncodedParams.value.push({
|
||||
id: idTicker.value++,
|
||||
|
|
@ -423,6 +371,72 @@ const deleteUrlEncodedParam = (index: number) => {
|
|||
)
|
||||
}
|
||||
|
||||
const convertWorkingParamsToBulkEditContent = (params: RawKeyValueEntry[]) => {
|
||||
return params
|
||||
.filter((param) => param.key !== "")
|
||||
.map((param) => `${!param.active ? "# " : ""}${param.key}: ${param.value}`)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
const bulkEditor = ref<HTMLElement | null>(null)
|
||||
const bulkEditContent = ref<string | undefined>(
|
||||
convertWorkingParamsToBulkEditContent(urlEncodedParams.value)
|
||||
)
|
||||
const isBulkEditing = ref(body.value.isBulkEditing)
|
||||
const wrapLines = useNestedSetting("WRAP_LINES", "httpUrlEncoded")
|
||||
|
||||
watch(isBulkEditing, () => {
|
||||
body.value.isBulkEditing = isBulkEditing.value
|
||||
})
|
||||
|
||||
// update working params when bulk edit content changes
|
||||
watch(bulkEditContent, () => {
|
||||
if (isBulkEditing.value && bulkEditContent.value !== undefined) {
|
||||
const res = parseRawKeyValueEntriesE(bulkEditContent.value)
|
||||
|
||||
if (E.isLeft(res)) {
|
||||
return
|
||||
}
|
||||
|
||||
workingUrlEncodedParams.value = [
|
||||
...res.right.map((entry) => ({
|
||||
id: idTicker.value++,
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
active: entry.active,
|
||||
})),
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const toggleBulkEdit = () => {
|
||||
isBulkEditing.value = !isBulkEditing.value
|
||||
|
||||
if (isBulkEditing.value) {
|
||||
bulkEditContent.value = convertWorkingParamsToBulkEditContent(
|
||||
workingUrlEncodedParams.value
|
||||
)
|
||||
} else {
|
||||
bulkEditContent.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
useCodemirror(
|
||||
bulkEditor,
|
||||
bulkEditContent,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "text/x-yaml",
|
||||
placeholder: t("state.bulk_mode_placeholder").toString(),
|
||||
lineWrapping: wrapLines,
|
||||
},
|
||||
linter,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
predefinedVariablesHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
const clearContent = () => {
|
||||
// set urlEncodedParams list to the initial state
|
||||
workingUrlEncodedParams.value = [
|
||||
|
|
@ -434,7 +448,7 @@ const clearContent = () => {
|
|||
},
|
||||
]
|
||||
|
||||
bulkUrlEncodedParams.value = ""
|
||||
bulkEditContent.value = ""
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export type SettingsDef = {
|
|||
importCurl: boolean
|
||||
codeGen: boolean
|
||||
cookie: boolean
|
||||
multipartFormdata: boolean
|
||||
}
|
||||
|
||||
CURRENT_INTERCEPTOR_ID: string
|
||||
|
|
@ -96,6 +97,7 @@ export const getDefaultSettings = (): SettingsDef => ({
|
|||
importCurl: true,
|
||||
codeGen: true,
|
||||
cookie: true,
|
||||
multipartFormdata: true,
|
||||
},
|
||||
|
||||
// Set empty because interceptor module will set the default value
|
||||
|
|
@ -245,9 +247,9 @@ export function toggleNestedSetting<
|
|||
>(settingKey: K, property: P) {
|
||||
settingsStore.dispatch({
|
||||
dispatcher: "toggleNestedSetting",
|
||||
// @ts-expect-error TS is not able to understand the type semantics here
|
||||
payload: {
|
||||
settingKey,
|
||||
// @ts-expect-error TS is not able to understand the type semantics here
|
||||
property,
|
||||
},
|
||||
})
|
||||
|
|
@ -262,7 +264,6 @@ export function applySetting<K extends keyof SettingsDef>(
|
|||
payload: {
|
||||
// @ts-expect-error TS is not able to understand the type semantics here
|
||||
settingKey,
|
||||
// @ts-expect-error TS is not able to understand the type semantics here
|
||||
value,
|
||||
},
|
||||
})
|
||||
|
|
@ -275,9 +276,9 @@ export function applyNestedSetting<
|
|||
>(settingKey: K, property: P, value: R) {
|
||||
settingsStore.dispatch({
|
||||
dispatcher: "applyNestedSetting",
|
||||
// @ts-expect-error TS is not able to understand the type semantics here
|
||||
payload: {
|
||||
settingKey,
|
||||
// @ts-expect-error TS is not able to understand the type semantics here
|
||||
property,
|
||||
value,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ const SettingsDefSchema = z.object({
|
|||
importCurl: z.boolean().catch(true),
|
||||
codeGen: z.boolean().catch(true),
|
||||
cookie: z.boolean().catch(true),
|
||||
multipartFormdata: z.boolean().catch(true),
|
||||
})
|
||||
),
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import V5_VERSION from "./v/5"
|
|||
import V6_VERSION from "./v/6"
|
||||
import V7_VERSION, { HoppRESTHeaders, HoppRESTParams } from "./v/7"
|
||||
import V8_VERSION, { HoppRESTAuth } from "./v/8"
|
||||
import V9_VERSION, { HoppRESTReqBody, HoppRESTRequestResponses } from "./v/9"
|
||||
import V9_VERSION, { HoppRESTRequestResponses } from "./v/9"
|
||||
import V10_VERSION, { HoppRESTReqBody } from "./v/10"
|
||||
|
||||
export * from "./content-types"
|
||||
|
||||
|
|
@ -50,19 +51,20 @@ export {
|
|||
|
||||
export {
|
||||
FormDataKeyValue,
|
||||
HoppRESTReqBody,
|
||||
HoppRESTResponseOriginalRequest,
|
||||
HoppRESTRequestResponse,
|
||||
HoppRESTRequestResponses,
|
||||
} from "./v/9"
|
||||
|
||||
export { HoppRESTReqBody } from "./v/10"
|
||||
|
||||
const versionedObject = z.object({
|
||||
// v is a stringified number
|
||||
v: z.string().regex(/^\d+$/).transform(Number),
|
||||
})
|
||||
|
||||
export const HoppRESTRequest = createVersionedEntity({
|
||||
latestVersion: 9,
|
||||
latestVersion: 10,
|
||||
versionMap: {
|
||||
0: V0_VERSION,
|
||||
1: V1_VERSION,
|
||||
|
|
@ -74,6 +76,7 @@ export const HoppRESTRequest = createVersionedEntity({
|
|||
7: V7_VERSION,
|
||||
8: V8_VERSION,
|
||||
9: V9_VERSION,
|
||||
10: V10_VERSION,
|
||||
},
|
||||
getVersion(data) {
|
||||
// For V1 onwards we have the v string storing the number
|
||||
|
|
@ -116,7 +119,7 @@ const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
|
|||
responses: lodashIsEqualEq,
|
||||
})
|
||||
|
||||
export const RESTReqSchemaVersion = "9"
|
||||
export const RESTReqSchemaVersion = "10"
|
||||
|
||||
export type HoppRESTParam = HoppRESTRequest["params"][number]
|
||||
export type HoppRESTHeader = HoppRESTRequest["headers"][number]
|
||||
|
|
|
|||
58
packages/hoppscotch-data/src/rest/v/10.ts
Normal file
58
packages/hoppscotch-data/src/rest/v/10.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { z } from "zod"
|
||||
import { FormDataKeyValue, V9_SCHEMA } from "./9"
|
||||
import { defineVersion } from "verzod"
|
||||
|
||||
export const HoppRESTReqBody = z.union([
|
||||
z.object({
|
||||
contentType: z.literal(null),
|
||||
body: z.literal(null).catch(null),
|
||||
}),
|
||||
z.object({
|
||||
contentType: z.literal("multipart/form-data"),
|
||||
body: z.array(FormDataKeyValue).catch([]),
|
||||
showIndividualContentType: z.boolean().optional().catch(false),
|
||||
isBulkEditing: z.boolean().optional().catch(false),
|
||||
}),
|
||||
z.object({
|
||||
contentType: z.literal("application/octet-stream"),
|
||||
body: z.instanceof(File).nullable().catch(null),
|
||||
}),
|
||||
z.object({
|
||||
contentType: z.literal("application/x-www-form-urlencoded"),
|
||||
body: z.string().catch(""),
|
||||
isBulkEditing: z.boolean().optional().catch(false),
|
||||
}),
|
||||
z.object({
|
||||
contentType: z.union([
|
||||
z.literal("application/json"),
|
||||
z.literal("application/ld+json"),
|
||||
z.literal("application/hal+json"),
|
||||
z.literal("application/vnd.api+json"),
|
||||
z.literal("application/xml"),
|
||||
z.literal("text/xml"),
|
||||
z.literal("binary"),
|
||||
z.literal("text/html"),
|
||||
z.literal("text/plain"),
|
||||
]),
|
||||
body: z.string().catch(""),
|
||||
}),
|
||||
])
|
||||
|
||||
export type HoppRESTReqBody = z.infer<typeof HoppRESTReqBody>
|
||||
|
||||
export const V10_SCHEMA = V9_SCHEMA.extend({
|
||||
v: z.literal("10"),
|
||||
body: HoppRESTReqBody,
|
||||
})
|
||||
|
||||
export default defineVersion({
|
||||
schema: V10_SCHEMA,
|
||||
initial: false,
|
||||
up(old: z.infer<typeof V9_SCHEMA>) {
|
||||
// no breaking changes
|
||||
return {
|
||||
...old,
|
||||
v: "10" as const,
|
||||
}
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue