BodyParameters.vue (hoppscotch-2.2.1) | : | BodyParameters.vue (hoppscotch-3.0.0) | ||
---|---|---|---|---|
<template> | <template> | |||
<div> | <div> | |||
<div | <div | |||
class="sticky z-10 flex items-center justify-between flex-1 pl-4 border-b bg-primary border-dividerLight top-upperTertiaryStickyFold" | class="sticky z-10 flex items-center justify-between pl-4 border-b bg-prim ary border-dividerLight top-upperMobileStickyFold sm:top-upperMobileTertiaryStic kyFold" | |||
> | > | |||
<label class="font-semibold text-secondaryLight"> | <label class="font-semibold text-secondaryLight"> | |||
{{ $t("request.body") }} | {{ t("request.body") }} | |||
</label> | </label> | |||
<div class="flex"> | <div class="flex"> | |||
<ButtonSecondary | <ButtonSecondary | |||
v-tippy="{ theme: 'tooltip' }" | v-tippy="{ theme: 'tooltip' }" | |||
to="https://docs.hoppscotch.io/features/body" | to="https://docs.hoppscotch.io/features/body" | |||
blank | blank | |||
:title="$t('app.wiki')" | :title="t('app.wiki')" | |||
svg="help-circle" | :icon="IconHelpCircle" | |||
/> | /> | |||
<ButtonSecondary | <ButtonSecondary | |||
v-tippy="{ theme: 'tooltip' }" | v-tippy="{ theme: 'tooltip' }" | |||
:title="$t('action.clear_all')" | :title="t('action.clear_all')" | |||
svg="trash-2" | :icon="IconTrash2" | |||
@click.native="clearContent" | @click="clearContent" | |||
/> | /> | |||
<ButtonSecondary | <ButtonSecondary | |||
v-tippy="{ theme: 'tooltip' }" | v-tippy="{ theme: 'tooltip' }" | |||
:title="$t('add.new')" | :title="t('add.new')" | |||
svg="plus" | :icon="IconPlus" | |||
@click.native="addBodyParam" | @click="addBodyParam" | |||
/> | /> | |||
</div> | </div> | |||
</div> | </div> | |||
<div | ||||
v-for="(param, index) in workingParams" | <draggable | |||
:key="`param-${index}`" | v-model="workingParams" | |||
class="flex border-b divide-x divide-dividerLight border-dividerLight" | item-key="id" | |||
animation="250" | ||||
handle=".draggable-handle" | ||||
draggable=".draggable-content" | ||||
ghost-class="cursor-move" | ||||
chosen-class="bg-primaryLight" | ||||
drag-class="cursor-grabbing" | ||||
> | > | |||
<SmartEnvInput | <template #item="{ element: { entry }, index }"> | |||
v-model="param.key" | <div | |||
:placeholder="`${$t('count.parameter', { count: index + 1 })}`" | class="flex border-b divide-x divide-dividerLight border-dividerLight | |||
styles=" | draggable-content group" | |||
bg-transparent | > | |||
flex | <span> | |||
flex-1 | <ButtonSecondary | |||
py-1 | v-tippy="{ | |||
px-4 | theme: 'tooltip', | |||
" | delay: [500, 20], | |||
@change=" | content: | |||
updateBodyParam(index, { | index !== workingParams?.length - 1 | |||
key: $event, | ? t('action.drag_to_reorder') | |||
value: param.value, | : null, | |||
active: param.active, | }" | |||
isFile: param.isFile, | :icon="IconGripVertical" | |||
}) | class="cursor-auto text-primary hover:text-primary" | |||
" | :class="{ | |||
/> | 'draggable-handle group-hover:text-secondaryLight !cursor-grab': | |||
<div v-if="param.isFile" class="file-chips-container hide-scrollbar"> | index !== workingParams?.length - 1, | |||
<div class="space-x-2 file-chips-wrapper"> | }" | |||
<SmartFileChip | tabindex="-1" | |||
v-for="(file, fileIndex) in param.value" | /> | |||
:key="`param-${index}-file-${fileIndex}`" | </span> | |||
> | <SmartEnvInput | |||
{{ file.name }} | v-model="entry.key" | |||
</SmartFileChip> | :placeholder="`${t('count.parameter', { count: index + 1 })}`" | |||
</div> | @change=" | |||
</div> | updateBodyParam(index, { | |||
<span v-else class="flex flex-1"> | key: $event, | |||
<SmartEnvInput | value: entry.value, | |||
v-model="param.value" | active: entry.active, | |||
:placeholder="`${$t('count.value', { count: index + 1 })}`" | isFile: entry.isFile, | |||
styles=" | }) | |||
bg-transparent | " | |||
flex | ||||
flex-1 | ||||
py-1 | ||||
px-4 | ||||
" | ||||
@change=" | ||||
updateBodyParam(index, { | ||||
key: param.key, | ||||
value: $event, | ||||
active: param.active, | ||||
isFile: param.isFile, | ||||
}) | ||||
" | ||||
/> | ||||
</span> | ||||
<span> | ||||
<label :for="`attachment${index}`" class="p-0"> | ||||
<input | ||||
:id="`attachment${index}`" | ||||
:ref="`attachment${index}`" | ||||
:name="`attachment${index}`" | ||||
type="file" | ||||
multiple | ||||
class="p-1 transition cursor-pointer file:transition file:cursor-poi | ||||
nter text-secondaryLight hover:text-secondaryDark file:mr-2 file:py-1 file:px-4 | ||||
file:rounded file:border-0 file:text-tiny text-tiny file:text-secondary hover:fi | ||||
le:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark" | ||||
@change="setRequestAttachment(index, param, $event)" | ||||
/> | /> | |||
</label> | <div v-if="entry.isFile" class="file-chips-container"> | |||
</span> | <div class="space-x-2 file-chips-wrapper"> | |||
<span> | <SmartFileChip | |||
<ButtonSecondary | v-for="(file, fileIndex) in entry.value" | |||
v-tippy="{ theme: 'tooltip' }" | :key="`param-${index}-file-${fileIndex}`" | |||
:title=" | >{{ file.name }}</SmartFileChip | |||
param.hasOwnProperty('active') | > | |||
? param.active | </div> | |||
? $t('action.turn_off') | </div> | |||
: $t('action.turn_on') | <span v-else class="flex flex-1"> | |||
: $t('action.turn_off') | <SmartEnvInput | |||
" | v-model="entry.value" | |||
:svg=" | :placeholder="`${t('count.value', { count: index + 1 })}`" | |||
param.hasOwnProperty('active') | @change=" | |||
? param.active | updateBodyParam(index, { | |||
? 'check-circle' | key: entry.key, | |||
: 'circle' | value: $event, | |||
: 'check-circle' | active: entry.active, | |||
" | isFile: entry.isFile, | |||
color="green" | }) | |||
@click.native=" | " | |||
updateBodyParam(index, { | /> | |||
key: param.key, | </span> | |||
value: param.value, | <span> | |||
active: param.hasOwnProperty('active') ? !param.active : false, | <label :for="`attachment${index}`" class="p-0"> | |||
isFile: param.isFile, | <input | |||
}) | :id="`attachment${index}`" | |||
" | :name="`attachment${index}`" | |||
/> | type="file" | |||
</span> | multiple | |||
<span> | class="p-1 cursor-pointer transition file:transition file:cursor | |||
<ButtonSecondary | -pointer text-secondaryLight hover:text-secondaryDark file:mr-2 file:py-1 file:p | |||
v-tippy="{ theme: 'tooltip' }" | x-4 file:rounded file:border-0 file:text-tiny text-tiny file:text-secondary hove | |||
:title="$t('action.remove')" | r:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark" | |||
svg="trash" | @change="setRequestAttachment(index, entry, $event)" | |||
color="red" | /> | |||
@click.native="deleteBodyParam(index)" | </label> | |||
/> | </span> | |||
</span> | <span> | |||
</div> | <ButtonSecondary | |||
v-tippy="{ theme: 'tooltip' }" | ||||
:title=" | ||||
entry.hasOwnProperty('active') | ||||
? entry.active | ||||
? t('action.turn_off') | ||||
: t('action.turn_on') | ||||
: t('action.turn_off') | ||||
" | ||||
:icon=" | ||||
entry.hasOwnProperty('active') | ||||
? entry.active | ||||
? IconCheckCircle | ||||
: IconCircle | ||||
: IconCheckCircle | ||||
" | ||||
color="green" | ||||
@click=" | ||||
updateBodyParam(index, { | ||||
key: entry.key, | ||||
value: entry.value, | ||||
active: entry.hasOwnProperty('active') | ||||
? !entry.active | ||||
: false, | ||||
isFile: entry.isFile, | ||||
}) | ||||
" | ||||
/> | ||||
</span> | ||||
<span> | ||||
<ButtonSecondary | ||||
v-tippy="{ theme: 'tooltip' }" | ||||
:title="t('action.remove')" | ||||
:icon="IconTrash" | ||||
color="red" | ||||
@click="deleteBodyParam(index)" | ||||
/> | ||||
</span> | ||||
</div> | ||||
</template> | ||||
</draggable> | ||||
<div | <div | |||
v-if="bodyParams.length === 0" | v-if="workingParams.length === 0" | |||
class="flex flex-col items-center justify-center p-4 text-secondaryLight" | class="flex flex-col items-center justify-center p-4 text-secondaryLight" | |||
> | > | |||
<img | <img | |||
:src="`/images/states/${$colorMode.value}/upload_single_file.svg`" | :src="`/images/states/${colorMode.value}/upload_single_file.svg`" | |||
loading="lazy" | loading="lazy" | |||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4" | class="inline-flex flex-col object-contain object-center w-16 h-16 my-4" | |||
:alt="`${$t('empty.body')}`" | :alt="`${t('empty.body')}`" | |||
/> | /> | |||
<span class="pb-4 text-center"> | <span class="pb-4 text-center">{{ t("empty.body") }}</span> | |||
{{ $t("empty.body") }} | ||||
</span> | ||||
<ButtonSecondary | <ButtonSecondary | |||
:label="`${$t('add.new')}`" | :label="`${t('add.new')}`" | |||
filled | filled | |||
svg="plus" | :icon="IconPlus" | |||
class="mb-4" | class="mb-4" | |||
@click.native="addBodyParam" | @click="addBodyParam" | |||
/> | /> | |||
</div> | </div> | |||
</div> | </div> | |||
</template> | </template> | |||
<script setup lang="ts"> | <script setup lang="ts"> | |||
import { ref, Ref, watch } from "@nuxtjs/composition-api" | import IconHelpCircle from "~icons/lucide/help-circle" | |||
import IconTrash2 from "~icons/lucide/trash-2" | ||||
import IconPlus from "~icons/lucide/plus" | ||||
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 { flow, pipe } from "fp-ts/function" | ||||
import * as O from "fp-ts/Option" | ||||
import * as A from "fp-ts/Array" | ||||
import { FormDataKeyValue } from "@hoppscotch/data" | import { FormDataKeyValue } from "@hoppscotch/data" | |||
import isEqual from "lodash/isEqual" | import { isEqual, clone } from "lodash-es" | |||
import { clone } from "lodash" | import draggable from "vuedraggable" | |||
import { pluckRef, useI18n, useToast } from "~/helpers/utils/composables" | import { pluckRef } from "@composables/ref" | |||
import { useI18n } from "@composables/i18n" | ||||
import { useToast } from "@composables/toast" | ||||
import { useColorMode } from "@composables/theming" | ||||
import { useRESTRequestBody } from "~/newstore/RESTSession" | import { useRESTRequestBody } from "~/newstore/RESTSession" | |||
type WorkingFormDataKeyValue = { id: number; entry: FormDataKeyValue } | ||||
const colorMode = useColorMode() | ||||
const t = useI18n() | const t = useI18n() | |||
const toast = useToast() | const toast = useToast() | |||
const idTicker = ref(0) | ||||
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null) | const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null) | |||
const bodyParams = pluckRef<any, any>(useRESTRequestBody(), "body") as Ref< | const bodyParams = pluckRef<any, any>(useRESTRequestBody(), "body") | |||
FormDataKeyValue[] | ||||
> | ||||
// The UI representation of the parameters list (has the empty end param) | // The UI representation of the parameters list (has the empty end param) | |||
const workingParams = ref<FormDataKeyValue[]>([ | const workingParams = ref<WorkingFormDataKeyValue[]>([ | |||
{ | { | |||
key: "", | id: idTicker.value++, | |||
value: "", | entry: { | |||
active: true, | key: "", | |||
isFile: false, | value: "", | |||
active: true, | ||||
isFile: false, | ||||
}, | ||||
}, | }, | |||
]) | ]) | |||
// Rule: Working Params always have last element is always an empty param | // Rule: Working Params always have last element is always an empty param | |||
watch(workingParams, (paramsList) => { | watch(workingParams, (paramsList) => { | |||
if (paramsList.length > 0 && paramsList[paramsList.length - 1].key !== "") { | if ( | |||
paramsList.length > 0 && | ||||
paramsList[paramsList.length - 1].entry.key !== "" | ||||
) { | ||||
workingParams.value.push({ | workingParams.value.push({ | |||
key: "", | id: idTicker.value++, | |||
value: "", | entry: { | |||
active: true, | key: "", | |||
isFile: false, | value: "", | |||
active: true, | ||||
isFile: false, | ||||
}, | ||||
}) | }) | |||
} | } | |||
}) | }) | |||
// Sync logic between params and working params | // Sync logic between params and working params | |||
watch( | watch( | |||
bodyParams, | bodyParams, | |||
(newParamsList) => { | (newParamsList) => { | |||
if (!Array.isArray(newParamsList)) return | ||||
// Sync should overwrite working params | // Sync should overwrite working params | |||
const filteredWorkingParams = workingParams.value.filter( | const filteredWorkingParams = pipe( | |||
(e) => e.key !== "" | workingParams.value, | |||
A.filterMap( | ||||
flow( | ||||
O.fromPredicate((e) => e.entry.key !== ""), | ||||
O.map((e) => e.entry) | ||||
) | ||||
) | ||||
) | ) | |||
if (!isEqual(newParamsList, filteredWorkingParams)) { | if (!isEqual(newParamsList, filteredWorkingParams)) { | |||
workingParams.value = newParamsList | workingParams.value = pipe( | |||
newParamsList, | ||||
A.map((x) => ({ id: idTicker.value++, entry: x })) | ||||
) | ||||
} | } | |||
}, | }, | |||
{ immediate: true } | { immediate: true } | |||
) | ) | |||
watch(workingParams, (newWorkingParams) => { | watch(workingParams, (newWorkingParams) => { | |||
const fixedParams = newWorkingParams.filter((e) => e.key !== "") | const fixedParams = pipe( | |||
newWorkingParams, | ||||
A.filterMap( | ||||
flow( | ||||
O.fromPredicate((e) => e.entry.key !== ""), | ||||
O.map((e) => e.entry) | ||||
) | ||||
) | ||||
) | ||||
if (!isEqual(bodyParams.value, fixedParams)) { | if (!isEqual(bodyParams.value, fixedParams)) { | |||
bodyParams.value = fixedParams | bodyParams.value = fixedParams | |||
} | } | |||
}) | }) | |||
const addBodyParam = () => { | const addBodyParam = () => { | |||
workingParams.value.push({ | workingParams.value.push({ | |||
key: "", | id: idTicker.value++, | |||
value: "", | entry: { | |||
active: true, | key: "", | |||
isFile: false, | value: "", | |||
active: true, | ||||
isFile: false, | ||||
}, | ||||
}) | }) | |||
} | } | |||
const updateBodyParam = (index: number, param: FormDataKeyValue) => { | const updateBodyParam = (index: number, entry: FormDataKeyValue) => { | |||
workingParams.value = workingParams.value.map((h, i) => | workingParams.value = workingParams.value.map((h, i) => | |||
i === index ? param : h | i === index ? { id: h.id, entry } : h | |||
) | ) | |||
} | } | |||
const deleteBodyParam = (index: number) => { | const deleteBodyParam = (index: number) => { | |||
const paramsBeforeDeletion = clone(workingParams.value) | const paramsBeforeDeletion = clone(workingParams.value) | |||
if ( | if ( | |||
!( | !( | |||
paramsBeforeDeletion.length > 0 && | paramsBeforeDeletion.length > 0 && | |||
index === paramsBeforeDeletion.length - 1 | index === paramsBeforeDeletion.length - 1 | |||
skipping to change at line 278 | skipping to change at line 342 | |||
}) | }) | |||
} | } | |||
workingParams.value.splice(index, 1) | workingParams.value.splice(index, 1) | |||
} | } | |||
const clearContent = () => { | const clearContent = () => { | |||
// set params list to the initial state | // set params list to the initial state | |||
workingParams.value = [ | workingParams.value = [ | |||
{ | { | |||
key: "", | id: idTicker.value++, | |||
value: "", | entry: { | |||
active: true, | key: "", | |||
isFile: false, | value: "", | |||
active: true, | ||||
isFile: false, | ||||
}, | ||||
}, | }, | |||
] | ] | |||
} | } | |||
const setRequestAttachment = ( | const setRequestAttachment = ( | |||
index: number, | index: number, | |||
entry: FormDataKeyValue, | entry: FormDataKeyValue, | |||
event: InputEvent | event: InputEvent | |||
) => { | ) => { | |||
// check if file exists or not | // check if file exists or not | |||
skipping to change at line 310 | skipping to change at line 377 | |||
const fileEntry: FormDataKeyValue = { | const fileEntry: FormDataKeyValue = { | |||
...entry, | ...entry, | |||
isFile: true, | isFile: true, | |||
value: Array.from((event.target as HTMLInputElement).files!), | value: Array.from((event.target as HTMLInputElement).files!), | |||
} | } | |||
updateBodyParam(index, fileEntry) | updateBodyParam(index, fileEntry) | |||
} | } | |||
</script> | </script> | |||
<style scoped lang="scss"> | <style lang="scss" scoped> | |||
.file-chips-container { | .file-chips-container { | |||
@apply flex flex-1; | @apply flex flex-1; | |||
@apply whitespace-nowrap; | @apply whitespace-nowrap; | |||
@apply overflow-auto; | @apply overflow-auto; | |||
@apply bg-transparent; | @apply bg-transparent; | |||
.file-chips-wrapper { | .file-chips-wrapper { | |||
@apply flex; | @apply flex; | |||
@apply p-1; | @apply p-1; | |||
@apply w-0; | @apply w-0; | |||
End of changes. 33 change blocks. | ||||
158 lines changed or deleted | 226 lines changed or added |