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:
Akash K 2024-12-19 18:52:05 +05:30 committed by GitHub
parent 6fd93b9f5d
commit 0ae21e2c2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 271 additions and 72 deletions

View file

@ -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 = (

View file

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

View file

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

View file

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

View file

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

View 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,
}
},
})