From 0069f51ea460803b892509205b969d70ed47bdcb Mon Sep 17 00:00:00 2001 From: Francisco Emanuel de Sales Pereira Date: Wed, 2 Nov 2022 09:25:22 -0300 Subject: [PATCH] feat: added inline environment variable edit button (#2813) * refactor: changes v-if render to v-show on Environments tabs * feat: adds selectText prop to EnvInput * feat: adds editing variable name to env Details modal * feat: adds actions to invoke edit env modals * feat: adds edit action to tooltip env * refactor: adds destructuring assignment on action handlers for edit env modals * refactor: fix comment on environment modals action * chore: minor ui improvements * refactor: change text selecion prop on EnvInput to something more meaningful * refactor: removes comment on HoppEnvironment extension * refactor: renames isTextSelected EnvInput prop to selectTextOnMount * refactor: remove type definition of automatic inferrable variables * refactor: edit environment call to only allow accepted types * feat: introduce type safe action arguments * fix: revert v-show to v-if * chore: minor ui improvements Co-authored-by: Liyas Thomas Co-authored-by: Andrew Bastin --- .../hoppscotch-app/assets/scss/styles.scss | 4 +- .../src/components/environments/index.vue | 4 +- .../components/environments/my/Details.vue | 2 + .../src/components/environments/my/index.vue | 17 ++++ .../components/environments/teams/Details.vue | 2 + .../components/environments/teams/index.vue | 16 +++- .../src/components/smart/EnvInput.vue | 16 +++- .../hoppscotch-app/src/helpers/actions.ts | 91 +++++++++++++++++-- .../editor/extensions/HoppEnvironment.ts | 40 +++++--- .../src/helpers/editor/themes/baseTheme.ts | 14 ++- 10 files changed, 176 insertions(+), 30 deletions(-) diff --git a/packages/hoppscotch-app/assets/scss/styles.scss b/packages/hoppscotch-app/assets/scss/styles.scss index 4da6bc012..2f585bb51 100644 --- a/packages/hoppscotch-app/assets/scss/styles.scss +++ b/packages/hoppscotch-app/assets/scss/styles.scss @@ -132,6 +132,7 @@ a { .cm-tooltip { .tippy-box { + @apply shadow-none; @apply fixed; @apply inline-flex; @apply -mt-6; @@ -166,10 +167,9 @@ a { } .env-icon { + @apply transition; @apply inline-flex; @apply items-center; - @apply mr-1; - @apply text-accentDark; } } diff --git a/packages/hoppscotch-app/src/components/environments/index.vue b/packages/hoppscotch-app/src/components/environments/index.vue index d5ccf86b2..df9b86520 100644 --- a/packages/hoppscotch-app/src/components/environments/index.vue +++ b/packages/hoppscotch-app/src/components/environments/index.vue @@ -155,9 +155,9 @@ @update-selected-team="updateSelectedTeam" /> - + Environment["variables"] }>(), { diff --git a/packages/hoppscotch-app/src/components/environments/my/index.vue b/packages/hoppscotch-app/src/components/environments/my/index.vue index 1979197c7..17557af95 100644 --- a/packages/hoppscotch-app/src/components/environments/my/index.vue +++ b/packages/hoppscotch-app/src/components/environments/my/index.vue @@ -64,6 +64,7 @@ :show="showModalDetails" :action="action" :editing-environment-index="editingEnvironmentIndex" + :editing-variable-name="editingVariableName" @hide-modal="displayModalEdit(false)" /> ("edit") const editingEnvironmentIndex = ref(null) +const editingVariableName = ref("") const displayModalAdd = (shouldDisplay: boolean) => { action.value = "new" @@ -122,4 +126,17 @@ const editEnvironment = (environmentIndex: number | "Global") => { const resetSelectedData = () => { editingEnvironmentIndex.value = null } + +defineActionHandler( + "modals.my.environment.edit", + ({ envName, variableName }) => { + editingVariableName.value = variableName + const envIndex: number = environments.value.findIndex( + (environment: Environment) => { + return environment.name === envName + } + ) + editEnvironment(envIndex >= 0 ? envIndex : "Global") + } +) diff --git a/packages/hoppscotch-app/src/components/environments/teams/Details.vue b/packages/hoppscotch-app/src/components/environments/teams/Details.vue index 1a3abe80b..904f0ad02 100644 --- a/packages/hoppscotch-app/src/components/environments/teams/Details.vue +++ b/packages/hoppscotch-app/src/components/environments/teams/Details.vue @@ -65,6 +65,7 @@ /> (), { diff --git a/packages/hoppscotch-app/src/components/environments/teams/index.vue b/packages/hoppscotch-app/src/components/environments/teams/index.vue index e4910d614..0ef9bdac1 100644 --- a/packages/hoppscotch-app/src/components/environments/teams/index.vue +++ b/packages/hoppscotch-app/src/components/environments/teams/index.vue @@ -102,6 +102,7 @@ :action="action" :editing-environment="editingEnvironment" :editing-team-id="team?.id" + :editing-variable-name="editingVariableName" :is-viewer="team?.myRole === 'VIEWER'" @hide-modal="displayModalEdit(false)" /> @@ -125,6 +126,7 @@ import IconPlus from "~icons/lucide/plus" import IconArchive from "~icons/lucide/archive" import IconHelpCircle from "~icons/lucide/help-circle" import { Team } from "~/helpers/backend/graphql" +import { defineActionHandler } from "~/helpers/actions" const t = useI18n() @@ -132,7 +134,7 @@ const colorMode = useColorMode() type SelectedTeam = Team | undefined -defineProps<{ +const props = defineProps<{ team: SelectedTeam teamEnvironments: TeamEnvironment[] adapterError: GQLError | null @@ -143,6 +145,7 @@ const showModalImportExport = ref(false) const showModalDetails = ref(false) const action = ref<"new" | "edit">("edit") const editingEnvironment = ref(null) +const editingVariableName = ref("") const displayModalAdd = (shouldDisplay: boolean) => { action.value = "new" @@ -178,4 +181,15 @@ const getErrorMessage = (err: GQLError) => { } } } + +defineActionHandler( + "modals.team.environment.edit", + ({ envName, variableName }) => { + editingVariableName.value = variableName + const teamEnvToEdit = props.teamEnvironments.find( + (environment) => environment.environment.name === envName + ) + if (teamEnvToEdit) editEnvironment(teamEnvToEdit) + } +) diff --git a/packages/hoppscotch-app/src/components/smart/EnvInput.vue b/packages/hoppscotch-app/src/components/smart/EnvInput.vue index 2da72850b..dbcbf36d8 100644 --- a/packages/hoppscotch-app/src/components/smart/EnvInput.vue +++ b/packages/hoppscotch-app/src/components/smart/EnvInput.vue @@ -27,7 +27,7 @@ import { keymap, tooltips, } from "@codemirror/view" -import { EditorState, Extension } from "@codemirror/state" +import { EditorSelection, EditorState, Extension } from "@codemirror/state" import { clone } from "lodash-es" import { history, historyKeymap } from "@codemirror/commands" import { inputTheme } from "~/helpers/editor/themes/baseTheme" @@ -42,6 +42,7 @@ const props = withDefaults( styles?: string envs?: { key: string; value: string; source: string }[] | null focus?: boolean + selectTextOnMount?: boolean readonly?: boolean }>(), { @@ -203,15 +204,28 @@ const initView = (el: any) => { }) } +const triggerTextSelection = () => { + nextTick(() => { + view.value?.focus() + view.value?.dispatch({ + selection: EditorSelection.create([ + EditorSelection.range(0, props.modelValue.length), + ]), + }) + }) +} + onMounted(() => { if (editor.value) { if (!view.value) initView(editor.value) + if (props.selectTextOnMount) triggerTextSelection() } }) watch(editor, () => { if (editor.value) { if (!view.value) initView(editor.value) + if (props.selectTextOnMount) triggerTextSelection() } else { view.value?.destroy() view.value = undefined diff --git a/packages/hoppscotch-app/src/helpers/actions.ts b/packages/hoppscotch-app/src/helpers/actions.ts index 89f43640e..deb0dbe3b 100644 --- a/packages/hoppscotch-app/src/helpers/actions.ts +++ b/packages/hoppscotch-app/src/helpers/actions.ts @@ -22,6 +22,8 @@ export type HoppAction = | "modals.search.toggle" // Shows the search modal | "modals.support.toggle" // Shows the support modal | "modals.share.toggle" // Shows the share modal + | "modals.my.environment.edit" // Edit current personal environment + | "modals.team.environment.edit" // Edit current team environment | "navigation.jump.rest" // Jump to REST page | "navigation.jump.graphql" // Jump to GraphQL page | "navigation.jump.realtime" // Jump to realtime page @@ -36,31 +38,101 @@ export type HoppAction = | "response.file.download" // Download response as file | "response.copy" // Copy response to clipboard +/** + * Defines the arguments, if present for a given type that is required to be passed on + * invocation and will be passed to action handlers. + * + * This type is supposed to be an object with the key being one of the actions mentioned above. + * The value to the key can be anything. + * If an action has no argument, you do not need to add it to this type. + * + * NOTE: We can't enforce type checks to make sure the key is Action, you + * will know if you got something wrong if there is a type error in this file + */ +type HoppActionArgs = { + "modals.my.environment.edit": { + envName: string + variableName: string + } + "modals.team.environment.edit": { + envName: string + variableName: string + } +} + +/** + * HoppActions which require arguments for their invocation + */ +type HoppActionWithArgs = keyof HoppActionArgs + +/** + * HoppActions which do not require arguments for their invocation + */ +type HoppActionWithNoArgs = Exclude + +/** + * Resolves the argument type for a given HoppAction + */ +type ArgOfHoppAction = A extends HoppActionWithArgs + ? HoppActionArgs[A] + : undefined + +/** + * Resolves the action function for a given HoppAction, used by action handler function defs + */ +type ActionFunc = A extends HoppActionWithArgs + ? (arg: ArgOfHoppAction) => void + : () => void + type BoundActionList = { // eslint-disable-next-line no-unused-vars - [_ in HoppAction]?: Array<() => void> + [A in HoppAction]?: Array> } const boundActions: BoundActionList = {} export const activeActions$ = new BehaviorSubject([]) -export function bindAction(action: HoppAction, handler: () => void) { +export function bindAction( + action: A, + handler: ActionFunc +) { if (boundActions[action]) { boundActions[action]?.push(handler) } else { - boundActions[action] = [handler] + // 'any' assertion because TypeScript doesn't seem to be able to figure out the links. + boundActions[action] = [handler] as any } activeActions$.next(Object.keys(boundActions) as HoppAction[]) } -export function invokeAction(action: HoppAction) { - boundActions[action]?.forEach((handler) => handler()) +type InvokeActionFunc = { + (action: HoppActionWithNoArgs, args?: undefined): void + (action: A, args: ArgOfHoppAction): void } -export function unbindAction(action: HoppAction, handler: () => void) { - boundActions[action] = boundActions[action]?.filter((x) => x !== handler) +/** + * Invokes a action, triggering action handlers if any registered. + * The second argument parameter is optional if your action has no args required + * @param action The action to fire + * @param args The argument passed to the action handler. Optional if action has no args required + */ +export const invokeAction: InvokeActionFunc = ( + action: A, + args: ArgOfHoppAction +) => { + boundActions[action]?.forEach((handler) => handler(args!)) +} + +export function unbindAction( + action: A, + handler: ActionFunc +) { + // 'any' assertion because TypeScript doesn't seem to be able to figure out the links. + boundActions[action] = boundActions[action]?.filter( + (x) => x !== handler + ) as any if (boundActions[action]?.length === 0) { delete boundActions[action] @@ -69,7 +141,10 @@ export function unbindAction(action: HoppAction, handler: () => void) { activeActions$.next(Object.keys(boundActions) as HoppAction[]) } -export function defineActionHandler(action: HoppAction, handler: () => void) { +export function defineActionHandler( + action: A, + handler: ActionFunc +) { onMounted(() => { bindAction(action, handler) }) diff --git a/packages/hoppscotch-app/src/helpers/editor/extensions/HoppEnvironment.ts b/packages/hoppscotch-app/src/helpers/editor/extensions/HoppEnvironment.ts index 497459c89..5acfadce7 100644 --- a/packages/hoppscotch-app/src/helpers/editor/extensions/HoppEnvironment.ts +++ b/packages/hoppscotch-app/src/helpers/editor/extensions/HoppEnvironment.ts @@ -16,6 +16,7 @@ import { getAggregateEnvs, getSelectedEnvironmentType, } from "~/newstore/environments" +import { invokeAction } from "~/helpers/actions" const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g @@ -58,17 +59,13 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => ) return null - const envName = - aggregateEnvs.find( - (env) => env.key === text.slice(start - from, end - from) - // env.key === word.slice(wordSelection.from + 2, wordSelection.to - 2) - )?.sourceEnv ?? "Choose an Environment" + const parsedEnvKey = text.slice(start - from, end - from) - 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 tooltipEnv = aggregateEnvs.find((env) => env.key === parsedEnvKey) + + const envName = tooltipEnv?.sourceEnv ?? "Choose an Environment" + + const envValue = tooltipEnv?.value ?? "Not found" const result = parseTemplateStringE(envValue, aggregateEnvs) @@ -76,9 +73,27 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => const selectedEnvType = getSelectedEnvironmentType() - const envTypeIcon = `${ + const envTypeIcon = `${ selectedEnvType === "TEAM_ENV" ? "people" : "person" }` + + const appendEditAction = (tooltip: HTMLElement) => { + const editIcon = document.createElement("span") + editIcon.className = + "env-icon ml-2 text-accent cursor-pointer hover:text-accentDark" + editIcon.addEventListener("click", () => { + const isPersonalEnv = + envName === "Global" || selectedEnvType !== "TEAM_ENV" + const action = isPersonalEnv ? "my" : "team" + invokeAction(`modals.${action}.environment.edit`, { + envName, + variableName: parsedEnvKey, + }) + }) + editIcon.innerHTML = `drive_file_rename_outline` + tooltip.appendChild(editIcon) + } + return { pos: start, end: to, @@ -90,11 +105,12 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => const kbd = document.createElement("kbd") const icon = document.createElement("span") icon.innerHTML = envTypeIcon - icon.className = "env-icon" + icon.className = "env-icon mr-2" kbd.textContent = finalEnv tooltipContainer.appendChild(icon) tooltipContainer.appendChild(document.createTextNode(`${envName} `)) tooltipContainer.appendChild(kbd) + if (tooltipEnv) appendEditAction(tooltipContainer) tooltipContainer.className = "tippy-content" dom.className = "tippy-box" dom.dataset.theme = "tooltip" diff --git a/packages/hoppscotch-app/src/helpers/editor/themes/baseTheme.ts b/packages/hoppscotch-app/src/helpers/editor/themes/baseTheme.ts index 2e3567b94..24e65634e 100644 --- a/packages/hoppscotch-app/src/helpers/editor/themes/baseTheme.ts +++ b/packages/hoppscotch-app/src/helpers/editor/themes/baseTheme.ts @@ -102,11 +102,14 @@ export const baseTheme = EditorView.theme({ border: "none", borderRadius: "4px", }, + ".cm-tooltip-arrow": { + color: "var(--tooltip-color)", + }, ".cm-tooltip-arrow:after": { - borderTopColor: "var(--tooltip-color) !important", + borderTopColor: "inherit !important", }, ".cm-tooltip-arrow:before": { - borderTopColor: "var(--tooltip-color) !important", + borderTopColor: "inherit !important", }, ".cm-tooltip.cm-tooltip-autocomplete > ul": { fontFamily: "var(--font-mono)", @@ -234,11 +237,14 @@ export const inputTheme = EditorView.theme({ border: "none", borderRadius: "4px", }, + ".cm-tooltip-arrow": { + color: "var(--tooltip-color)", + }, ".cm-tooltip-arrow:after": { - borderTopColor: "var(--tooltip-color) !important", + borderTopColor: "currentColor !important", }, ".cm-tooltip-arrow:before": { - borderTopColor: "var(--tooltip-color) !important", + borderTopColor: "currentColor !important", }, ".cm-tooltip.cm-tooltip-autocomplete > ul": { fontFamily: "var(--font-mono)",