diff --git a/README.md b/README.md index d1ff7e7ae..937a64238 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ _Customized themes are synced with cloud / local session_ -🔥 **PWA:** Install as a [PWA](https://developers.google.com/web/progressive-web-apps) on your device. +🔥 **PWA:** Install as a [PWA](https://web.dev/what-are-pwas/) on your device. - Instant loading with Service Workers - Offline support diff --git a/packages/hoppscotch-app/components/app/PaneLayout.vue b/packages/hoppscotch-app/components/app/PaneLayout.vue index e5b0f7079..7064db7f3 100644 --- a/packages/hoppscotch-app/components/app/PaneLayout.vue +++ b/packages/hoppscotch-app/components/app/PaneLayout.vue @@ -6,21 +6,26 @@ '!flex-row-reverse': SIDEBAR_ON_LEFT && mdAndLarger, }" :horizontal="!mdAndLarger" + @resize="setPaneEvent($event, 'vertical')" > - + @@ -29,7 +34,7 @@ @@ -42,8 +47,9 @@ import { Splitpanes, Pane } from "splitpanes" import "splitpanes/dist/splitpanes.css" import { breakpointsTailwind, useBreakpoints } from "@vueuse/core" -import { computed, useSlots } from "@nuxtjs/composition-api" +import { computed, useSlots, ref } from "@nuxtjs/composition-api" import { useSetting } from "~/newstore/settings" +import { setLocalConfig, getLocalConfig } from "~/newstore/localpersistence" const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT") @@ -57,4 +63,60 @@ const SIDEBAR = useSetting("SIDEBAR") const slots = useSlots() const hasSidebar = computed(() => !!slots.sidebar) + +const props = defineProps({ + layoutId: { + type: String, + default: null, + }, +}) + +type PaneEvent = { + max: number + min: number + size: number +} + +const PANE_SIDEBAR_SIZE = ref(25) +const PANE_MAIN_SIZE = ref(75) +const PANE_MAIN_TOP_SIZE = ref(45) +const PANE_MAIN_BOTTOM_SIZE = ref(65) + +if (!COLUMN_LAYOUT.value) { + PANE_MAIN_TOP_SIZE.value = 50 + PANE_MAIN_BOTTOM_SIZE.value = 50 +} + +function setPaneEvent(event: PaneEvent[], type: "vertical" | "horizontal") { + if (!props.layoutId) return + const storageKey = `${props.layoutId}-pane-config-${type}` + setLocalConfig(storageKey, JSON.stringify(event)) +} + +function populatePaneEvent() { + if (!props.layoutId) return + + const verticalPaneData = getPaneData("vertical") + if (verticalPaneData) { + const [mainPane, sidebarPane] = verticalPaneData + PANE_MAIN_SIZE.value = mainPane?.size + PANE_SIDEBAR_SIZE.value = sidebarPane?.size + } + + const horizontalPaneData = getPaneData("horizontal") + if (horizontalPaneData) { + const [mainTopPane, mainBottomPane] = horizontalPaneData + PANE_MAIN_TOP_SIZE.value = mainTopPane?.size + PANE_MAIN_BOTTOM_SIZE.value = mainBottomPane?.size + } +} + +function getPaneData(type: "vertical" | "horizontal"): PaneEvent[] | null { + const storageKey = `${props.layoutId}-pane-config-${type}` + const paneEvent = getLocalConfig(storageKey) + if (!paneEvent) return null + return JSON.parse(paneEvent) +} + +populatePaneEvent() diff --git a/packages/hoppscotch-app/components/collections/index.vue b/packages/hoppscotch-app/components/collections/index.vue index c842ce96e..6b49dba8f 100644 --- a/packages/hoppscotch-app/components/collections/index.vue +++ b/packages/hoppscotch-app/components/collections/index.vue @@ -11,6 +11,7 @@ autocomplete="off" :placeholder="$t('action.search')" class="py-2 pl-4 pr-2 bg-transparent" + :disabled="collectionsType.type == 'team-collections'" /> { confirmChange.value = false setRestReq(props.request) } else if (!active.value) { - confirmChange.value = true + // If the current request is the same as the request to be loaded in, there is no data loss + const currentReq = getRESTRequest() + + if (isEqualHoppRESTRequest(currentReq, props.request)) { + setRestReq(props.request) + } else { + confirmChange.value = true + } } else { const currentReqWithNoChange = active.value.req const currentFullReq = getRESTRequest() diff --git a/packages/hoppscotch-app/components/collections/teams/Request.vue b/packages/hoppscotch-app/components/collections/teams/Request.vue index 8ef25dc74..22dc35046 100644 --- a/packages/hoppscotch-app/components/collections/teams/Request.vue +++ b/packages/hoppscotch-app/components/collections/teams/Request.vue @@ -261,7 +261,7 @@ const active = useReadonlyStream(restSaveContext$, null) const isSelected = computed( () => props.picked && - props.picked.pickedType === "teams-collection" && + props.picked.pickedType === "teams-request" && props.picked.requestID === props.requestIndex ) @@ -312,7 +312,7 @@ const selectRequest = () => { if (props.saveRequest) { emit("select", { picked: { - pickedType: "teams-collection", + pickedType: "teams-request", requestID: props.requestIndex, }, }) diff --git a/packages/hoppscotch-app/components/http/Parameters.vue b/packages/hoppscotch-app/components/http/Parameters.vue index b070ce125..22668ea0d 100644 --- a/packages/hoppscotch-app/components/http/Parameters.vue +++ b/packages/hoppscotch-app/components/http/Parameters.vue @@ -1,363 +1,13 @@ diff --git a/packages/hoppscotch-app/components/http/PathVariables.vue b/packages/hoppscotch-app/components/http/PathVariables.vue new file mode 100644 index 000000000..e46fe6cb2 --- /dev/null +++ b/packages/hoppscotch-app/components/http/PathVariables.vue @@ -0,0 +1,271 @@ + + + diff --git a/packages/hoppscotch-app/components/http/QueryParams.vue b/packages/hoppscotch-app/components/http/QueryParams.vue new file mode 100644 index 000000000..b070ce125 --- /dev/null +++ b/packages/hoppscotch-app/components/http/QueryParams.vue @@ -0,0 +1,363 @@ + + + diff --git a/packages/hoppscotch-app/components/http/Request.vue b/packages/hoppscotch-app/components/http/Request.vue index d2d1b1385..f9600f608 100644 --- a/packages/hoppscotch-app/components/http/Request.vue +++ b/packages/hoppscotch-app/components/http/Request.vue @@ -348,7 +348,8 @@ const newSendRequest = async () => { const ensureMethodInEndpoint = () => { if ( !/^http[s]?:\/\//.test(newEndpoint.value) && - !newEndpoint.value.startsWith("<<") + !newEndpoint.value.startsWith("<<") && + !newEndpoint.value.startsWith("{{") ) { const domain = newEndpoint.value.split(/[/:#?]+/)[0] if (domain === "localhost" || /([0-9]+\.)*[0-9]/.test(domain)) { diff --git a/packages/hoppscotch-app/components/http/RequestOptions.vue b/packages/hoppscotch-app/components/http/RequestOptions.vue index c9dc954ba..b3582d8b1 100644 --- a/packages/hoppscotch-app/components/http/RequestOptions.vue +++ b/packages/hoppscotch-app/components/http/RequestOptions.vue @@ -7,7 +7,7 @@ @@ -50,6 +50,7 @@ import { useReadonlyStream } from "~/helpers/utils/composables" import { restActiveHeadersCount$, restActiveParamsCount$, + restActiveVarsCount$, usePreRequestScript, useTestScript, } from "~/newstore/RESTSession" @@ -76,6 +77,16 @@ const newActiveParamsCount$ = useReadonlyStream( null ) +const newActiveVarsCount$ = useReadonlyStream( + restActiveVarsCount$.pipe( + map((e) => { + if (e === 0) return null + return `${e}` + }) + ), + null +) + const newActiveHeadersCount$ = useReadonlyStream( restActiveHeadersCount$.pipe( map((e) => { diff --git a/packages/hoppscotch-app/components/smart/EnvInput.vue b/packages/hoppscotch-app/components/smart/EnvInput.vue index 761cc4564..81bc6bbdf 100644 --- a/packages/hoppscotch-app/components/smart/EnvInput.vue +++ b/packages/hoppscotch-app/components/smart/EnvInput.vue @@ -35,10 +35,13 @@ import { EditorState, Extension } from "@codemirror/state" import clone from "lodash/clone" import { tooltips } from "@codemirror/tooltip" import { history, historyKeymap } from "@codemirror/history" +import { HoppRESTVar } from "@hoppscotch/data" import { inputTheme } from "~/helpers/editor/themes/baseTheme" import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment" import { useReadonlyStream } from "~/helpers/utils/composables" import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments" +import { HoppReactiveVarPlugin } from "~/helpers/editor/extensions/HoppVariable" +import { restVars$ } from "~/newstore/RESTSession" const props = withDefaults( defineProps<{ @@ -46,6 +49,7 @@ const props = withDefaults( placeholder: string styles: string envs: { key: string; value: string; source: string }[] | null + vars: { key: string; value: string }[] | null focus: boolean readonly: boolean }>(), @@ -54,6 +58,7 @@ const props = withDefaults( placeholder: "", styles: "", envs: null, + vars: null, focus: false, readonly: false, } @@ -109,6 +114,7 @@ let pastedValue: string | null = null const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref< AggregateEnvironment[] > +const aggregateVars = useReadonlyStream(restVars$, []) as Ref const envVars = computed(() => props.envs @@ -120,7 +126,17 @@ const envVars = computed(() => : aggregateEnvs.value ) +const varVars = computed(() => + props.vars + ? props.vars.map((x) => ({ + key: x.key, + value: x.value, + })) + : aggregateVars.value +) + const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view) +const varTooltipPlugin = new HoppReactiveVarPlugin(varVars, view) const initView = (el: any) => { const extensions: Extension = [ @@ -146,6 +162,7 @@ const initView = (el: any) => { position: "absolute", }), envTooltipPlugin, + varTooltipPlugin, placeholderExt(props.placeholder), EditorView.domEventHandlers({ paste(ev) { diff --git a/packages/hoppscotch-app/helpers/curl/__tests__/curlparser.spec.js b/packages/hoppscotch-app/helpers/curl/__tests__/curlparser.spec.js index 0d649a2cf..7b3b8352c 100644 --- a/packages/hoppscotch-app/helpers/curl/__tests__/curlparser.spec.js +++ b/packages/hoppscotch-app/helpers/curl/__tests__/curlparser.spec.js @@ -809,6 +809,37 @@ const samples = [ testScript: "", }), }, + { + command: `curl https://example.com -d "alpha=beta&request_id=4"`, + response: makeRESTRequest({ + method: "POST", + name: "Untitled request", + endpoint: "https://example.com/", + auth: { + authType: "none", + authActive: true, + }, + body: { + contentType: "application/x-www-form-urlencoded", + body: rawKeyValueEntriesToString([ + { + active: true, + key: "alpha", + value: "beta", + }, + { + active: true, + key: "request_id", + value: "4", + }, + ]), + }, + params: [], + headers: [], + preRequestScript: "", + testScript: "", + }), + }, ] describe("Parse curl command to Hopp REST Request", () => { diff --git a/packages/hoppscotch-app/helpers/curl/curlparser.ts b/packages/hoppscotch-app/helpers/curl/curlparser.ts index 21b0b48b1..27f2ef223 100644 --- a/packages/hoppscotch-app/helpers/curl/curlparser.ts +++ b/packages/hoppscotch-app/helpers/curl/curlparser.ts @@ -93,7 +93,8 @@ export const parseCurlCommand = (curlCommand: string) => { hasBodyBeenParsed = true } else if ( rawContentType.includes("application/x-www-form-urlencoded") && - !!pairs + !!pairs && + Array.isArray(rawData) ) { body = pairs.map((p) => p.join(": ")).join("\n") || null contentType = "application/x-www-form-urlencoded" diff --git a/packages/hoppscotch-app/helpers/editor/extensions/HoppEnvironment.ts b/packages/hoppscotch-app/helpers/editor/extensions/HoppEnvironment.ts index 6111a560f..020b3f18f 100644 --- a/packages/hoppscotch-app/helpers/editor/extensions/HoppEnvironment.ts +++ b/packages/hoppscotch-app/helpers/editor/extensions/HoppEnvironment.ts @@ -16,7 +16,7 @@ import { getAggregateEnvs, } from "~/newstore/environments" -const HOPP_ENVIRONMENT_REGEX = /(<<\w+>>)/g +const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g const HOPP_ENV_HIGHLIGHT = "cursor-help transition rounded px-1 focus:outline-none mx-0.5 env-highlight" @@ -44,8 +44,9 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => let start = pos let end = pos - while (start > from && /\w/.test(text[start - from - 1])) start-- - while (end < to && /\w/.test(text[end - from])) end++ + while (start > from && /[a-zA-Z0-9-_]+/.test(text[start - from - 1])) + start-- + while (end < to && /[a-zA-Z0-9-_]+/.test(text[end - from])) end++ if ( (start === pos && side < 0) || diff --git a/packages/hoppscotch-app/helpers/editor/extensions/HoppVariable.ts b/packages/hoppscotch-app/helpers/editor/extensions/HoppVariable.ts new file mode 100644 index 000000000..7d4729b6c --- /dev/null +++ b/packages/hoppscotch-app/helpers/editor/extensions/HoppVariable.ts @@ -0,0 +1,149 @@ +import { watch, Ref } from "@nuxtjs/composition-api" +import { Compartment } from "@codemirror/state" +import { hoverTooltip } from "@codemirror/tooltip" +import { + Decoration, + EditorView, + MatchDecorator, + ViewPlugin, +} from "@codemirror/view" +import * as E from "fp-ts/Either" +import { HoppRESTVar, parseTemplateStringE } from "@hoppscotch/data" + +const HOPP_ENVIRONMENT_REGEX = /({{\w+}})/g + +const HOPP_ENV_HIGHLIGHT = + "cursor-help transition rounded px-1 focus:outline-none mx-0.5 env-highlight" +const HOPP_ENV_HIGHLIGHT_FOUND = + "bg-accentDark text-accentContrast hover:bg-accent" +const HOPP_ENV_HIGHLIGHT_NOT_FOUND = + "bg-red-500 text-accentContrast hover:bg-red-600" + +const cursorTooltipField = (aggregateEnvs: HoppRESTVar[]) => + hoverTooltip( + (view, pos, side) => { + const { from, to, text } = view.state.doc.lineAt(pos) + + // TODO: When Codemirror 6 allows this to work (not make the + // popups appear half of the time) use this implementation + // const wordSelection = view.state.wordAt(pos) + // if (!wordSelection) return null + // const word = view.state.doc.sliceString( + // wordSelection.from - 2, + // wordSelection.to + 2 + // ) + // if (!HOPP_ENVIRONMENT_REGEX.test(word)) return null + + // Tracking the start and the end of the words + let start = pos + let end = pos + + while (start > from && /\w/.test(text[start - from - 1])) start-- + while (end < to && /\w/.test(text[end - from])) end++ + + if ( + (start === pos && side < 0) || + (end === pos && side > 0) || + !HOPP_ENVIRONMENT_REGEX.test( + text.slice(start - from - 2, end - from + 2) + ) + ) + return null + + const envValue = + aggregateEnvs.find( + (env) => env.key === text.slice(start - from, end - from) + // env.key === word.slice(wordSelection.from + 2, wordSelection.to - 2) + )?.value ?? "not found" + + const result = parseTemplateStringE(envValue, aggregateEnvs) + + const finalEnv = E.isLeft(result) ? "error" : result.right + + return { + pos: start, + end: to, + above: true, + arrow: true, + create() { + const dom = document.createElement("span") + const xmp = document.createElement("xmp") + xmp.textContent = finalEnv + dom.appendChild(xmp) + dom.className = "tooltip-theme" + return { dom } + }, + } + }, + // HACK: This is a hack to fix hover tooltip not coming half of the time + // https://github.com/codemirror/tooltip/blob/765c463fc1d5afcc3ec93cee47d72606bed27e1d/src/tooltip.ts#L622 + // Still doesn't fix the not showing up some of the time issue, but this is atleast more consistent + { hoverTime: 1 } as any + ) + +function checkEnv(env: string, aggregateEnvs: HoppRESTVar[]) { + const className = aggregateEnvs.find( + (k: { key: string }) => k.key === env.slice(2, -2) + ) + ? HOPP_ENV_HIGHLIGHT_FOUND + : HOPP_ENV_HIGHLIGHT_NOT_FOUND + + return Decoration.mark({ + class: `${HOPP_ENV_HIGHLIGHT} ${className}`, + }) +} + +const getMatchDecorator = (aggregateEnvs: HoppRESTVar[]) => + new MatchDecorator({ + regexp: HOPP_ENVIRONMENT_REGEX, + decoration: (m) => checkEnv(m[0], aggregateEnvs), + }) + +export const environmentHighlightStyle = (aggregateEnvs: HoppRESTVar[]) => { + const decorator = getMatchDecorator(aggregateEnvs) + + return ViewPlugin.define( + (view) => ({ + decorations: decorator.createDeco(view), + update(u) { + this.decorations = decorator.updateDeco(u, this.decorations) + }, + }), + { + decorations: (v) => v.decorations, + } + ) +} + +export class HoppReactiveVarPlugin { + private compartment = new Compartment() + + private envs: HoppRESTVar[] = [] + + constructor( + envsRef: Ref, + private editorView: Ref + ) { + watch( + envsRef, + (envs) => { + this.envs = envs + + this.editorView.value?.dispatch({ + effects: this.compartment.reconfigure([ + cursorTooltipField(this.envs), + environmentHighlightStyle(this.envs), + ]), + }) + }, + { immediate: true } + ) + } + + get extension() { + return this.compartment.of([ + cursorTooltipField(this.envs), + environmentHighlightStyle(this.envs), + ]) + } +} diff --git a/packages/hoppscotch-app/helpers/editor/themes/baseTheme.ts b/packages/hoppscotch-app/helpers/editor/themes/baseTheme.ts index d1668f0f9..2c55f55bd 100644 --- a/packages/hoppscotch-app/helpers/editor/themes/baseTheme.ts +++ b/packages/hoppscotch-app/helpers/editor/themes/baseTheme.ts @@ -61,6 +61,8 @@ export const baseTheme = EditorView.theme({ }, ".cm-panels.cm-panels-top": { borderBottom: "1px solid var(--divider-light-color)", + top: "var(--lower-tertiary-sticky-fold) !important", + "z-index": "10", }, ".cm-panels.cm-panels-bottom": { borderTop: "1px solid var(--divider-light-color)", @@ -388,5 +390,7 @@ export const basicSetup: Extension = [ ...completionKeymap, ...lintKeymap, ]), - search(), + search({ + top: true, + }), ] diff --git a/packages/hoppscotch-app/helpers/functional/record.ts b/packages/hoppscotch-app/helpers/functional/record.ts index 7dd17fab4..ec57eaf10 100644 --- a/packages/hoppscotch-app/helpers/functional/record.ts +++ b/packages/hoppscotch-app/helpers/functional/record.ts @@ -1,3 +1,11 @@ +/** + * Converts an array of key-value tuples (for e.g ["key", "value"]), into a record. + * (for eg. output -> { "key": "value" }) + * NOTE: This function will discard duplicate key occurances and only keep the last occurance. If you do not want that behaviour, + * use `tupleWithSamesKeysToRecord`. + * @param tuples Array of tuples ([key, value]) + * @returns A record with value corresponding to the last occurance of that key + */ export const tupleToRecord = < KeyType extends string | number | symbol, ValueType @@ -5,5 +13,32 @@ export const tupleToRecord = < tuples: [KeyType, ValueType][] ): Record => tuples.length > 0 - ? (Object.assign as any)(...tuples.map(([key, val]) => ({ [key]: val }))) + ? (Object.assign as any)(...tuples.map(([key, val]) => ({ [key]: val }))) // This is technically valid, but we have no way of telling TypeScript it is valid. Hence the assertion : {} + +/** + * Converts an array of key-value tuples (for e.g ["key", "value"]), into a record. + * (for eg. output -> { "key": ["value"] }) + * NOTE: If you do not want the array as values (because of duplicate keys) and want to instead get the last occurance, use `tupleToRecord` + * @param tuples Array of tuples ([key, value]) + * @returns A Record with values being arrays corresponding to each key occurance + */ +export const tupleWithSameKeysToRecord = < + KeyType extends string | number | symbol, + ValueType +>( + tuples: [KeyType, ValueType][] +): Record => { + // By the end of the function we do ensure this typing, this can't be infered now though, hence the assertion + const out = {} as Record + + for (const [key, value] of tuples) { + if (!out[key]) { + out[key] = [value] + } else { + out[key].push(value) + } + } + + return out +} diff --git a/packages/hoppscotch-app/helpers/keybindings.ts b/packages/hoppscotch-app/helpers/keybindings.ts index 5a3473b90..c636b1c81 100644 --- a/packages/hoppscotch-app/helpers/keybindings.ts +++ b/packages/hoppscotch-app/helpers/keybindings.ts @@ -56,6 +56,7 @@ export const bindings: { "alt-q": "navigation.jump.graphql", "alt-w": "navigation.jump.realtime", "alt-d": "navigation.jump.documentation", + "alt-m": "navigation.jump.profile", "alt-s": "navigation.jump.settings", } diff --git a/packages/hoppscotch-app/helpers/shortcuts.js b/packages/hoppscotch-app/helpers/shortcuts.js index f9398bf1c..f18f34a11 100644 --- a/packages/hoppscotch-app/helpers/shortcuts.js +++ b/packages/hoppscotch-app/helpers/shortcuts.js @@ -103,7 +103,7 @@ export default [ label: "shortcut.navigation.settings", }, { - keys: [getPlatformAlternateKey(), "P"], + keys: [getPlatformAlternateKey(), "M"], label: "shortcut.navigation.profile", }, ], @@ -171,7 +171,7 @@ export const spotlight = [ icon: "arrow-right", }, { - keys: [getPlatformAlternateKey(), "P"], + keys: [getPlatformAlternateKey(), "M"], label: "shortcut.navigation.profile", action: "navigation.jump.profile", icon: "arrow-right", @@ -267,7 +267,7 @@ export const fuse = [ tags: ["settings", "jump", "page", "navigation", "account", "theme", "go"], }, { - keys: [getPlatformAlternateKey(), "P"], + keys: [getPlatformAlternateKey(), "M"], label: "shortcut.navigation.profile", action: "navigation.jump.profile", icon: "arrow-right", diff --git a/packages/hoppscotch-app/helpers/utils/EffectiveURL.ts b/packages/hoppscotch-app/helpers/utils/EffectiveURL.ts index fae74280e..1ee36c401 100644 --- a/packages/hoppscotch-app/helpers/utils/EffectiveURL.ts +++ b/packages/hoppscotch-app/helpers/utils/EffectiveURL.ts @@ -1,6 +1,10 @@ import * as A from "fp-ts/Array" +import * as E from "fp-ts/Either" +import * as O from "fp-ts/Option" +import * as RA from "fp-ts/ReadonlyArray" +import * as S from "fp-ts/string" import qs from "qs" -import { pipe } from "fp-ts/function" +import { flow, pipe } from "fp-ts/function" import { combineLatest, Observable } from "rxjs" import { map } from "rxjs/operators" import { @@ -9,14 +13,15 @@ import { HoppRESTRequest, parseTemplateString, parseBodyEnvVariables, - parseRawKeyValueEntries, Environment, HoppRESTHeader, HoppRESTParam, + parseRawKeyValueEntriesE, + parseTemplateStringE, } from "@hoppscotch/data" import { arrayFlatMap, arraySort } from "../functional/array" import { toFormData } from "../functional/formData" -import { tupleToRecord } from "../functional/record" +import { tupleWithSameKeysToRecord } from "../functional/record" import { getGlobalVariables } from "~/newstore/environments" export interface EffectiveHoppRESTRequest extends HoppRESTRequest { @@ -29,6 +34,7 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest { effectiveFinalHeaders: { key: string; value: string }[] effectiveFinalParams: { key: string; value: string }[] effectiveFinalBody: FormData | string | null + effectiveFinalVars: { key: string; value: string }[] } /** @@ -210,25 +216,40 @@ function getFinalBodyFromRequest( } if (request.body.contentType === "application/x-www-form-urlencoded") { - return pipe( + const parsedBodyRecord = pipe( request.body.body, - parseRawKeyValueEntries, + parseRawKeyValueEntriesE, + E.map( + flow( + RA.toArray, + /** + * Filtering out empty keys and non-active pairs. + */ + A.filter(({ active, key }) => active && !S.isEmpty(key)), - // Filter out active - A.filter((x) => x.active), - // Convert to tuple - A.map( - ({ key, value }) => - [ - parseTemplateString(key, envVariables), - parseTemplateString(value, envVariables), - ] as [string, string] - ), - // Tuple to Record object - tupleToRecord, - // Stringify - qs.stringify + /** + * Mapping each key-value to template-string-parser with either on array, + * which will be resolved in further steps. + */ + A.map(({ key, value }) => [ + parseTemplateStringE(key, envVariables), + parseTemplateStringE(value, envVariables), + ]), + + /** + * Filtering and mapping only right-eithers for each key-value as [string, string]. + */ + A.filterMap(([key, value]) => + E.isRight(key) && E.isRight(value) + ? O.some([key.right, value.right] as [string, string]) + : O.none + ), + tupleWithSameKeysToRecord, + (obj) => qs.stringify(obj, { indices: false }) + ) + ) ) + return E.isRight(parsedBodyRecord) ? parsedBodyRecord.right : null } if (request.body.contentType === "multipart/form-data") { @@ -298,15 +319,21 @@ export function getEffectiveRESTRequest( value: parseTemplateString(x.value, envVariables), })) ) + const effectiveFinalVars = request.vars const effectiveFinalBody = getFinalBodyFromRequest(request, envVariables) return { ...request, - effectiveFinalURL: parseTemplateString(request.endpoint, envVariables), + effectiveFinalURL: parseTemplateString( + request.endpoint, + envVariables, + request.vars + ), effectiveFinalHeaders, effectiveFinalParams, effectiveFinalBody, + effectiveFinalVars, } } diff --git a/packages/hoppscotch-app/locales/tw.json b/packages/hoppscotch-app/locales/tw.json index 6facfc2fb..0c390c118 100644 --- a/packages/hoppscotch-app/locales/tw.json +++ b/packages/hoppscotch-app/locales/tw.json @@ -1,5 +1,6 @@ { "action": { + "autoscroll": "自動捲動", "cancel": "取消", "choose_file": "選擇一個檔案", "clear": "清除", @@ -9,10 +10,11 @@ "delete": "刪除", "disconnect": "斷開連線", "dismiss": "忽略", - "download_file": "下載檔案", "dont_save": "不要儲存", + "download_file": "下載檔案", "duplicate": "複製", "edit": "編輯", + "filter_response": "篩選回應", "go_back": "返回", "label": "標籤", "learn_more": "瞭解更多", @@ -20,11 +22,14 @@ "more": "更多", "new": "新增", "no": "否", + "open_workspace": "開啟工作區", "paste": "貼上", "prettify": "美化", "remove": "移除", "restore": "還原", "save": "儲存", + "scroll_to_bottom": "捲動至底部", + "scroll_to_top": "捲動至頂部", "search": "搜尋", "send": "傳送", "start": "開始", @@ -46,10 +51,10 @@ "contact_us": "聯絡我們", "copy": "複製", "copy_user_id": "複製使用者驗證權杖", - "discord": "Discord", - "documentation": "幫助文件", "developer_option": "開發者選項", "developer_option_description": "協助開發和維護 Hoppscotch 的工具。", + "discord": "Discord", + "documentation": "幫助文件", "github": "GitHub", "help": "幫助與回饋", "home": "主頁", @@ -164,6 +169,7 @@ "profile": "登入以檢視您的設定檔", "protocols": "協議為空", "schema": "連線至 GraphQL 端點", + "shortcodes": "Shortcodes 為空", "team_name": "團隊名稱為空", "teams": "團隊為空", "tests": "沒有針對該請求的測試" @@ -197,9 +203,11 @@ "invalid_link": "連結無效", "invalid_link_description": "您點擊的連結無效或已過期。", "json_prettify_invalid_body": "無法美化無效的請求主體,處理 JSON 語法錯誤並重試", + "json_parsing_failed": "JSON 無效", "network_error": "似乎有網路錯誤。請再試一次。", "network_fail": "無法傳送請求", "no_duration": "無持續時間", + "no_results_found": "找不到結果", "script_fail": "無法執行預請求指令碼", "something_went_wrong": "發生了一些錯誤", "test_script_fail": "無法執行測試指令碼" @@ -266,15 +274,19 @@ "from_url": "從網址匯入", "gist_url": "輸入 Gist 網址", "json_description": "從 Hoppscotch 組合 JSON 檔匯入組合", - "title": "匯入" + "title": "匯入", + "import_from_url_success": "已匯入組合", + "import_from_url_invalid_file_format": "匯入組合時發生錯誤", + "import_from_url_invalid_type": "不支援此類型。可接受的值為 'hoppscotch'、'openapi'、'postman'、'insomnia'", + "import_from_url_invalid_fetch": "無法從網址取得資料" }, "layout": { - "column": "垂直布局", - "row": "水平布局", - "zen_mode": "專注模式", - "collapse_sidebar": "隱藏或顯示側邊欄", "collapse_collection": "隱藏或顯示組合", - "name": "配置" + "collapse_sidebar": "隱藏或顯示側邊欄", + "column": "垂直布局", + "name": "配置", + "row": "水平布局", + "zen_mode": "專注模式" }, "modal": { "collections": "組合", @@ -331,6 +343,11 @@ "body": "請求本體", "choose_language": "選擇語言", "content_type": "內容類型", + "content_type_titles": { + "others": "其他", + "structured": "結構", + "text": "文字" + }, "copy_link": "複製連結", "duration": "持續時間", "enter_curl": "輸入 cURL", @@ -341,6 +358,9 @@ "method": "方法", "name": "請求名稱", "new": "新請求", + "override": "覆寫", + "override_help": "在標頭設置 Content-Type", + "overriden": "已覆寫", "parameter_list": "查詢參數", "parameters": "參數", "path": "路徑", @@ -358,12 +378,11 @@ "type": "請求類型", "url": "網址", "variables": "變數", - "override": "覆寫", - "override_help": "在標頭設置 Content-Type", - "overriden": "已覆寫" + "view_my_links": "檢視我的連結" }, "response": { "body": "回應本體", + "filter_response_body": "篩選 JSON 回應本體 (使用 JSONPath 語法)", "headers": "回應標頭", "html": "HTML", "image": "影像", @@ -415,6 +434,8 @@ "proxy_use_toggle": "使用 Proxy 中介軟體傳送請求", "read_the": "閱讀", "reset_default": "重置為預設", + "short_codes": "快捷碼", + "short_codes_description": "我們為您打造的快捷碼。", "sidebar_on_left": "左側邊欄", "sync": "同步", "sync_collections": "組合", @@ -447,7 +468,7 @@ "documentation": "前往文件頁面", "forward": "前往下一頁面", "graphql": "前往 GraphQL 頁面", - "profile": "Go to Profile page", + "profile": "前往個人檔案頁面", "realtime": "前往實時頁面", "rest": "前往 REST 頁面", "settings": "前往設定頁面", @@ -476,6 +497,15 @@ "title": "主題" } }, + "shortcodes":{ + "actions":"操作", + "created_on": "建立於", + "deleted" : "已刪除快捷碼", + "method": "方法", + "not_found":"找不到快捷碼", + "short_code":"快捷碼", + "url": "網址" + }, "show": { "code": "顯示程式碼", "more": "顯示更多", @@ -487,7 +517,8 @@ "event_name": "事件名稱", "events": "事件", "log": "日誌", - "url": "網址" + "url": "網址", + "connection_not_authorized": "此 SocketIO 連線未使用任何驗證。" }, "sse": { "event_type": "事件類型", @@ -517,7 +548,19 @@ "loading": "正在載入……", "none": "無", "nothing_found": "沒有找到", - "waiting_send_request": "等待傳送請求" + "waiting_send_request": "等待傳送請求", + "subscribed_success": "成功訂閱此主題:{topic}", + "unsubscribed_success": "成功取消訂閱此主題:{topic}", + "subscribed_failed": "無法訂閱此主題:{topic}", + "unsubscribed_failed": "無法取消訂閱此主題:{topic}", + "published_message": "已將此訊息:{message} 發布至主題:{topic}", + "published_error": "將訊息:{topic} 發布至主題:{message} 時發生錯誤", + "message_received": "訊息:{message}已抵達主題:{topic}", + "mqtt_subscription_failed": "訂閱此主題時發生錯誤:{topic}", + "connection_lost": "失去連線", + "connection_failed": "連線失敗", + "connection_error": "連線失敗", + "reconnection_error": "重新連線失敗" }, "support": { "changelog": "閱讀更多有關最新版本的內容", diff --git a/packages/hoppscotch-app/newstore/RESTSession.ts b/packages/hoppscotch-app/newstore/RESTSession.ts index 10185ad67..7a9e7cfe4 100644 --- a/packages/hoppscotch-app/newstore/RESTSession.ts +++ b/packages/hoppscotch-app/newstore/RESTSession.ts @@ -4,6 +4,7 @@ import { FormDataKeyValue, HoppRESTHeader, HoppRESTParam, + HoppRESTVar, HoppRESTReqBody, HoppRESTRequest, RESTReqSchemaVersion, @@ -29,6 +30,7 @@ export const getDefaultRESTRequest = (): HoppRESTRequest => ({ endpoint: "https://echo.hoppscotch.io", name: "Untitled request", params: [], + vars: [], headers: [], method: "GET", auth: { @@ -80,6 +82,14 @@ const dispatchers = defineDispatchers({ }, } }, + setVars(curr: RESTSession, { entries }: { entries: HoppRESTVar[] }) { + return { + request: { + ...curr.request, + vars: entries, + }, + } + }, addParam(curr: RESTSession, { newParam }: { newParam: HoppRESTParam }) { return { request: { @@ -88,6 +98,14 @@ const dispatchers = defineDispatchers({ }, } }, + addVar(curr: RESTSession, { newVar }: { newVar: HoppRESTVar }) { + return { + request: { + ...curr.request, + vars: [...curr.request.vars, newVar], + }, + } + }, updateParam( curr: RESTSession, { index, updatedParam }: { index: number; updatedParam: HoppRESTParam } @@ -104,6 +122,22 @@ const dispatchers = defineDispatchers({ }, } }, + updateVar( + curr: RESTSession, + { index, updatedVar }: { index: number; updatedVar: HoppRESTVar } + ) { + const newVars = curr.request.vars.map((vari, i) => { + if (i === index) return updatedVar + else return vari + }) + + return { + request: { + ...curr.request, + vars: newVars, + }, + } + }, deleteParam(curr: RESTSession, { index }: { index: number }) { const newParams = curr.request.params.filter((_x, i) => i !== index) @@ -114,6 +148,16 @@ const dispatchers = defineDispatchers({ }, } }, + deleteVar(curr: RESTSession, { index }: { index: number }) { + const newVars = curr.request.vars.filter((_x, i) => i !== index) + + return { + request: { + ...curr.request, + vars: newVars, + }, + } + }, deleteAllParams(curr: RESTSession) { return { request: { @@ -373,6 +417,14 @@ export function setRESTParams(entries: HoppRESTParam[]) { }, }) } +export function setRESTVars(entries: HoppRESTVar[]) { + restSessionStore.dispatch({ + dispatcher: "setVars", + payload: { + entries, + }, + }) +} export function addRESTParam(newParam: HoppRESTParam) { restSessionStore.dispatch({ @@ -382,6 +434,14 @@ export function addRESTParam(newParam: HoppRESTParam) { }, }) } +export function addRESTVar(newVar: HoppRESTVar) { + restSessionStore.dispatch({ + dispatcher: "addVar", + payload: { + newVar, + }, + }) +} export function updateRESTParam(index: number, updatedParam: HoppRESTParam) { restSessionStore.dispatch({ @@ -392,6 +452,15 @@ export function updateRESTParam(index: number, updatedParam: HoppRESTParam) { }, }) } +export function updateRESTVar(index: number, updatedVar: HoppRESTVar) { + restSessionStore.dispatch({ + dispatcher: "updateVar", + payload: { + updatedVar, + index, + }, + }) +} export function deleteRESTParam(index: number) { restSessionStore.dispatch({ @@ -402,6 +471,15 @@ export function deleteRESTParam(index: number) { }) } +export function deleteRESTVar(index: number) { + restSessionStore.dispatch({ + dispatcher: "deleteVar", + payload: { + index, + }, + }) +} + export function deleteAllRESTParams() { restSessionStore.dispatch({ dispatcher: "deleteAllParams", @@ -592,12 +670,20 @@ export const restParams$ = restSessionStore.subject$.pipe( distinctUntilChanged() ) +export const restVars$ = restSessionStore.subject$.pipe( + pluck("request", "vars"), + distinctUntilChanged() +) + export const restActiveParamsCount$ = restParams$.pipe( map( (params) => params.filter((x) => x.active && (x.key !== "" || x.value !== "")).length ) ) +export const restActiveVarsCount$ = restVars$.pipe( + map((vars) => vars.filter((x) => x.key !== "" || x.value !== "").length) +) export const restMethod$ = restSessionStore.subject$.pipe( pluck("request", "method"), diff --git a/packages/hoppscotch-app/newstore/history.ts b/packages/hoppscotch-app/newstore/history.ts index 14bec21d3..453b86a7e 100644 --- a/packages/hoppscotch-app/newstore/history.ts +++ b/packages/hoppscotch-app/newstore/history.ts @@ -315,6 +315,7 @@ completedRESTResponse$.subscribe((res) => { method: res.req.method, name: res.req.name, params: res.req.params, + vars: res.req.vars, preRequestScript: res.req.preRequestScript, testScript: res.req.testScript, v: res.req.v, diff --git a/packages/hoppscotch-app/package.json b/packages/hoppscotch-app/package.json index f0ad8b55a..9482c74cb 100644 --- a/packages/hoppscotch-app/package.json +++ b/packages/hoppscotch-app/package.json @@ -58,7 +58,7 @@ "@codemirror/tooltip": "^0.19.16", "@codemirror/view": "^0.19.48", "@hoppscotch/codemirror-lang-graphql": "workspace:^0.2.0", - "@hoppscotch/data": "workspace:^0.4.2", + "@hoppscotch/data": "workspace:^0.4.3", "@hoppscotch/js-sandbox": "workspace:^2.0.0", "@nuxtjs/axios": "^5.13.6", "@nuxtjs/composition-api": "^0.32.0", diff --git a/packages/hoppscotch-app/pages/documentation.vue b/packages/hoppscotch-app/pages/documentation.vue index 8143c3bb5..a5b807a13 100644 --- a/packages/hoppscotch-app/pages/documentation.vue +++ b/packages/hoppscotch-app/pages/documentation.vue @@ -1,5 +1,5 @@