feat: UI of shortcode actions (#2347)

Co-authored-by: liyasthomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
Nivedin
2022-05-24 17:58:49 +05:30
committed by GitHub
parent 184914ba4f
commit cfa89a6ded
12 changed files with 632 additions and 119 deletions

View File

@@ -152,37 +152,49 @@
/>
<div
ref="saveTippyActions"
class="flex flex-col focus:outline-none"
class="flex flex-col divide-y-1 divide-primaryDark focus:outline-none"
tabindex="0"
role="menu"
@keyup.c="copyRequestAction.$el.click()"
@keyup.s="saveRequestAction.$el.click()"
@keyup.escape="saveOptions.tippy().hide()"
>
<SmartItem
ref="copyRequestAction"
:label="shareButtonText"
:svg="copyLinkIcon"
:loading="fetchingShareLink"
:shortcut="['C']"
@click.native="
() => {
copyRequest()
}
"
/>
<SmartItem
ref="saveRequestAction"
:label="`${t('request.save_as')}`"
svg="folder-plus"
:shortcut="['S']"
@click.native="
() => {
showSaveRequestModal = true
saveOptions.tippy().hide()
}
"
/>
<div class="flex flex-col space-y-1">
<SmartItem
ref="copyRequestAction"
:label="shareButtonText"
:svg="copyLinkIcon"
:loading="fetchingShareLink"
:shortcut="['C']"
@click.native="
() => {
copyRequest()
}
"
/>
<SmartAnchor
:label="`${t('request.view_my_links')}`"
to="/profile"
svg="arrow-right"
reverse
blank
class="pb-3 -ml-4 text-tiny text-secondaryLight"
/>
</div>
<div class="flex pt-3 pb-1">
<SmartItem
ref="saveRequestAction"
:label="`${t('request.save_as')}`"
svg="folder-plus"
:shortcut="['S']"
@click.native="
() => {
showSaveRequestModal = true
saveOptions.tippy().hide()
}
"
/>
</div>
</div>
</tippy>
</span>

View File

@@ -0,0 +1,135 @@
<template>
<div
class="table-row-groups lg:flex block my-6 lg:my-0 w-full border lg:border-0 divide-y lg:divide-y-0 lg:divide-x divide-dividerLight border-dividerLight"
>
<div class="table-column lg:w-1/5" :data-label="t('shortcodes.short_code')">
{{ shortcode.id }}
</div>
<div
class="table-column lg:w-1/5"
:class="requestLabelColor"
:data-label="t('shortcodes.method')"
>
{{ parseShortcodeRequest.method }}
</div>
<div class="table-column lg:w-3/5" :data-label="t('shortcodes.url')">
<div class="max-w-50 lg:max-w-90 truncate">
{{ parseShortcodeRequest.endpoint }}
</div>
</div>
<div
ref="timeStampRef"
class="table-column lg:w-1/5"
:data-label="t('shortcodes.created_on')"
>
<span v-tippy="{ theme: 'tooltip' }" :title="timeStamp">
{{ dateStamp }}
</span>
</div>
<div
class="flex items-center justify-center lg:w-1/5 px-3"
:data-label="t('shortcodes.actions')"
>
<SmartAnchor
v-tippy="{ theme: 'tooltip' }"
:title="t('action.open_workspace')"
:to="`https://hopp.sh/r/${shortcode.id}`"
blank
svg="external-link"
color="blue"
class="px-3"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
color="green"
:svg="copyIconRefs"
class="px-3"
@click.native="copyShortcode(shortcode.id)"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
svg="trash"
color="red"
class="px-3"
@click.native="deleteShortcode(shortcode.id)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "@nuxtjs/composition-api"
import { pipe } from "fp-ts/function"
import * as RR from "fp-ts/ReadonlyRecord"
import * as O from "fp-ts/Option"
import { translateToNewRequest } from "@hoppscotch/data"
import { useI18n, useToast } from "~/helpers/utils/composables"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { Shortcode } from "~/helpers/shortcodes/Shortcode"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
shortcode: Shortcode
}>()
const emit = defineEmits<{
(e: "delete-shortcode", codeID: string): void
}>()
const deleteShortcode = (codeID: string) => {
emit("delete-shortcode", codeID)
}
const requestMethodLabels = {
get: "text-green-500",
post: "text-yellow-500",
put: "text-blue-500",
delete: "text-red-500",
default: "text-gray-500",
} as const
const timeStampRef = ref()
const copyIconRefs = ref<"copy" | "check">("copy")
const parseShortcodeRequest = computed(() =>
pipe(props.shortcode.request, JSON.parse, translateToNewRequest)
)
const requestLabelColor = computed(() =>
pipe(
requestMethodLabels,
RR.lookup(parseShortcodeRequest.value.method.toLowerCase()),
O.getOrElseW(() => requestMethodLabels.default)
)
)
const dateStamp = computed(() =>
new Date(props.shortcode.createdOn).toLocaleDateString()
)
const timeStamp = computed(() =>
new Date(props.shortcode.createdOn).toLocaleTimeString()
)
const copyShortcode = (codeID: string) => {
copyToClipboard(`https://hopp.sh/r/${codeID}`)
toast.success(`${t("state.copied_to_clipboard")}`)
copyIconRefs.value = "check"
setTimeout(() => (copyIconRefs.value = "copy"), 1000)
}
</script>
<style lang="scss">
.table-column {
@apply flex items-center justify-between px-3 py-3;
}
.table-row-groups {
.table-column {
@apply before:text-secondary before:font-bold before:content-[attr(data-label)] lg:before:hidden;
}
}
</style>

View File

@@ -0,0 +1,3 @@
mutation DeleteShortcode($code: ID!) {
revokeShortcode(code: $code)
}

View File

@@ -0,0 +1,7 @@
query GetUserShortcodes($cursor: ID) {
myShortcodes(cursor: $cursor) {
id
request
createdOn
}
}

View File

@@ -0,0 +1,7 @@
subscription ShortcodeCreated {
myShortcodesCreated {
id
request
createdOn
}
}

View File

@@ -0,0 +1,5 @@
subscription ShortcodeDeleted {
myShortcodesRevoked {
id
}
}

View File

@@ -17,7 +17,7 @@ import {
GetCollectionTitleDocument,
} from "./graphql"
const BACKEND_PAGE_SIZE = 10
export const BACKEND_PAGE_SIZE = 10
const getCollectionChildrenIDs = async (collID: string) => {
const collsList: string[] = []

View File

@@ -4,8 +4,13 @@ import {
CreateShortcodeDocument,
CreateShortcodeMutation,
CreateShortcodeMutationVariables,
DeleteShortcodeDocument,
DeleteShortcodeMutation,
DeleteShortcodeMutationVariables,
} from "../graphql"
type DeleteShortcodeErrors = "shortcode/not_found"
export const createShortcode = (request: HoppRESTRequest) =>
runMutation<CreateShortcodeMutation, CreateShortcodeMutationVariables, "">(
CreateShortcodeDocument,
@@ -13,3 +18,12 @@ export const createShortcode = (request: HoppRESTRequest) =>
request: JSON.stringify(request),
}
)
export const deleteShortcode = (code: string) =>
runMutation<
DeleteShortcodeMutation,
DeleteShortcodeMutationVariables,
DeleteShortcodeErrors
>(DeleteShortcodeDocument, {
code,
})

View File

@@ -0,0 +1,8 @@
/**
* Defines how a Shortcode is represented in the ShortcodeListAdapter
*/
export interface Shortcode {
id: string
request: string
createdOn: Date
}

View File

@@ -0,0 +1,149 @@
import * as E from "fp-ts/Either"
import { BehaviorSubject, Subscription } from "rxjs"
import { GQLError, runGQLQuery, runGQLSubscription } from "../backend/GQLClient"
import {
GetUserShortcodesQuery,
GetUserShortcodesDocument,
ShortcodeCreatedDocument,
ShortcodeDeletedDocument,
} from "../backend/graphql"
import { BACKEND_PAGE_SIZE } from "../backend/helpers"
import { Shortcode } from "./Shortcode"
export default class ShortcodeListAdapter {
error$: BehaviorSubject<GQLError<string> | null>
loading$: BehaviorSubject<boolean>
shortcodes$: BehaviorSubject<GetUserShortcodesQuery["myShortcodes"]>
hasMoreShortcodes$: BehaviorSubject<boolean>
private timeoutHandle: ReturnType<typeof setTimeout> | null
private isDispose: boolean
private myShortcodesCreated: Subscription | null
private myShortcodesRevoked: Subscription | null
constructor(deferInit: boolean = false) {
this.error$ = new BehaviorSubject<GQLError<string> | null>(null)
this.loading$ = new BehaviorSubject<boolean>(false)
this.shortcodes$ = new BehaviorSubject<
GetUserShortcodesQuery["myShortcodes"]
>([])
this.hasMoreShortcodes$ = new BehaviorSubject<boolean>(true)
this.timeoutHandle = null
this.isDispose = false
this.myShortcodesCreated = null
this.myShortcodesRevoked = null
if (!deferInit) this.initialize()
}
unsubscribeSubscriptions() {
this.myShortcodesCreated?.unsubscribe()
this.myShortcodesRevoked?.unsubscribe()
}
initialize() {
if (this.timeoutHandle) throw new Error(`Adapter already initialized`)
if (this.isDispose) throw new Error(`Adapter has been disposed`)
this.fetchList()
this.registerSubscriptions()
}
public dispose() {
if (!this.timeoutHandle) throw new Error(`Adapter has not been initialized`)
if (!this.isDispose) throw new Error(`Adapter has been disposed`)
this.isDispose = true
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
this.unsubscribeSubscriptions()
}
fetchList() {
this.loadMore(true)
}
async loadMore(forcedAttempt = false) {
if (!this.hasMoreShortcodes$.value && !forcedAttempt) return
this.loading$.next(true)
const lastCodeID =
this.shortcodes$.value.length > 0
? this.shortcodes$.value[this.shortcodes$.value.length - 1].id
: undefined
const result = await runGQLQuery({
query: GetUserShortcodesDocument,
variables: {
cursor: lastCodeID,
},
})
if (E.isLeft(result)) {
this.error$.next(result.left)
console.error(result.left)
this.loading$.next(false)
throw new Error(`Failed fetching short codes list: ${result.left}`)
}
const fetchedResult = result.right.myShortcodes
this.pushNewShortcodes(fetchedResult)
if (fetchedResult.length !== BACKEND_PAGE_SIZE) {
this.hasMoreShortcodes$.next(false)
}
this.loading$.next(false)
}
private pushNewShortcodes(results: Shortcode[]) {
const userShortcodes = this.shortcodes$.value
userShortcodes.push(...results)
this.shortcodes$.next(userShortcodes)
}
private createShortcode(shortcode: Shortcode) {
const userShortcodes = this.shortcodes$.value
userShortcodes.unshift(shortcode)
this.shortcodes$.next(userShortcodes)
}
private deleteShortcode(codeId: string) {
const newShortcodes = this.shortcodes$.value.filter(
({ id }) => id !== codeId
)
this.shortcodes$.next(newShortcodes)
}
private registerSubscriptions() {
this.myShortcodesCreated = runGQLSubscription({
query: ShortcodeCreatedDocument,
}).subscribe((result) => {
if (E.isLeft(result)) {
console.error(result.left)
throw new Error(`Shortcode Create Error ${result.left}`)
}
this.createShortcode(result.right.myShortcodesCreated)
})
this.myShortcodesRevoked = runGQLSubscription({
query: ShortcodeDeletedDocument,
}).subscribe((result) => {
if (E.isLeft(result)) {
console.error(result.left)
throw new Error(`Shortcode Delete Error ${result.left}`)
}
this.deleteShortcode(result.right.myShortcodesRevoked.id)
})
}
}

View File

@@ -21,6 +21,7 @@
"more": "More",
"new": "New",
"no": "No",
"open_workspace": "Open workspace",
"paste": "Paste",
"prettify": "Prettify",
"remove": "Remove",
@@ -167,6 +168,7 @@
"profile": "Login in to view your profile",
"protocols": "Protocols are empty",
"schema": "Connect to a GraphQL endpoint to view schema",
"shortcodes": "Shortcodes are empty",
"team_name": "Team name empty",
"teams": "You don't belong to any teams",
"tests": "There are no tests for this request"
@@ -367,7 +369,8 @@
"title": "Request",
"type": "Request type",
"url": "URL",
"variables": "Variables"
"variables": "Variables",
"view_my_links": "View my links"
},
"response": {
"body": "Response Body",
@@ -422,6 +425,8 @@
"proxy_use_toggle": "Use the proxy middleware to send requests",
"read_the": "Read the",
"reset_default": "Reset to default",
"short_codes": "Short codes",
"short_codes_description": "Short codes which were created by you.",
"sidebar_on_left": "Sidebar on left",
"sync": "Synchronise",
"sync_collections": "Collections",
@@ -483,6 +488,15 @@
"title": "Theme"
}
},
"shortcodes":{
"actions":"Actions",
"created_on": "Created on",
"deleted" : "Shortcode deleted",
"method": "Method",
"not_found":"Shortcode not found",
"short_code":"Short code",
"url": "URL"
},
"show": {
"code": "Show code",
"collection": "Expand Collection Panel",

View File

@@ -3,7 +3,13 @@
<div class="container">
<div class="p-4">
<div
v-if="currentUser === null"
v-if="loadingCurrentUser"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<SmartSpinner class="mb-4" />
</div>
<div
v-else-if="currentUser === null"
class="flex flex-col items-center justify-center"
>
<img
@@ -79,102 +85,195 @@
</div>
<SmartTabs v-model="selectedProfileTab">
<SmartTab :id="'sync'" :label="t('settings.account')">
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.profile") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.profile_description") }}
</div>
<div class="py-4">
<label for="displayName">
{{ t("settings.profile_name") }}
</label>
<form
class="flex mt-2 md:max-w-sm"
@submit.prevent="updateDisplayName"
>
<input
id="displayName"
v-model="displayName"
class="input"
:placeholder="`${t('settings.profile_name')}`"
type="text"
autocomplete="off"
required
/>
<ButtonSecondary
filled
outline
:label="t('action.save')"
class="ml-2 min-w-16"
type="submit"
:loading="updatingDisplayName"
/>
</form>
</div>
<div class="py-4">
<label for="emailAddress">
{{ t("settings.profile_email") }}
</label>
<form
class="flex mt-2 md:max-w-sm"
@submit.prevent="updateEmailAddress"
>
<input
id="emailAddress"
v-model="emailAddress"
class="input"
:placeholder="`${t('settings.profile_name')}`"
type="email"
autocomplete="off"
required
/>
<ButtonSecondary
filled
outline
:label="t('action.save')"
class="ml-2 min-w-16"
type="submit"
:loading="updatingEmailAddress"
/>
</form>
</div>
</section>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.sync") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.sync_description") }}
</div>
<div class="py-4 space-y-4">
<div class="flex items-center">
<SmartToggle
:on="SYNC_COLLECTIONS"
@change="toggleSetting('syncCollections')"
>
{{ t("settings.sync_collections") }}
</SmartToggle>
<div class="grid grid-cols-1">
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.profile") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.profile_description") }}
</div>
<div class="flex items-center">
<SmartToggle
:on="SYNC_ENVIRONMENTS"
@change="toggleSetting('syncEnvironments')"
<div class="py-4">
<label for="displayName">
{{ t("settings.profile_name") }}
</label>
<form
class="flex mt-2 md:max-w-sm"
@submit.prevent="updateDisplayName"
>
{{ t("settings.sync_environments") }}
</SmartToggle>
<input
id="displayName"
v-model="displayName"
class="input"
:placeholder="`${t('settings.profile_name')}`"
type="text"
autocomplete="off"
required
/>
<ButtonSecondary
filled
outline
:label="t('action.save')"
class="ml-2 min-w-16"
type="submit"
:loading="updatingDisplayName"
/>
</form>
</div>
<div class="flex items-center">
<SmartToggle
:on="SYNC_HISTORY"
@change="toggleSetting('syncHistory')"
<div class="py-4">
<label for="emailAddress">
{{ t("settings.profile_email") }}
</label>
<form
class="flex mt-2 md:max-w-sm"
@submit.prevent="updateEmailAddress"
>
{{ t("settings.sync_history") }}
</SmartToggle>
<input
id="emailAddress"
v-model="emailAddress"
class="input"
:placeholder="`${t('settings.profile_name')}`"
type="email"
autocomplete="off"
required
/>
<ButtonSecondary
filled
outline
:label="t('action.save')"
class="ml-2 min-w-16"
type="submit"
:loading="updatingEmailAddress"
/>
</form>
</div>
</div>
</section>
</section>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.sync") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.sync_description") }}
</div>
<div class="py-4 space-y-4">
<div class="flex items-center">
<SmartToggle
:on="SYNC_COLLECTIONS"
@change="toggleSetting('syncCollections')"
>
{{ t("settings.sync_collections") }}
</SmartToggle>
</div>
<div class="flex items-center">
<SmartToggle
:on="SYNC_ENVIRONMENTS"
@change="toggleSetting('syncEnvironments')"
>
{{ t("settings.sync_environments") }}
</SmartToggle>
</div>
<div class="flex items-center">
<SmartToggle
:on="SYNC_HISTORY"
@change="toggleSetting('syncHistory')"
>
{{ t("settings.sync_history") }}
</SmartToggle>
</div>
</div>
</section>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.short_codes") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.short_codes_description") }}
</div>
<div class="relative py-4 overflow-x-auto hide-scrollbar">
<div
v-if="loading"
class="flex flex-col items-center justify-center"
>
<SmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{
t("state.loading")
}}</span>
</div>
<div
v-if="!loading && myShortcodes.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${$colorMode.value}/add_files.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-8"
:alt="`${t('empty.shortcodes')}`"
/>
<span class="mb-4 text-center">
{{ t("empty.shortcodes") }}
</span>
</div>
<div
v-else-if="!loading"
class="table w-full border-collapse table-auto"
>
<div
class="bg-primaryLight hidden lg:flex rounded-t w-full"
>
<div class="flex w-full">
<div class="w-1/5 p-3 font-semibold">
{{ t("shortcodes.short_code") }}
</div>
<div class="w-1/5 p-3 font-semibold">
{{ t("shortcodes.method") }}
</div>
<div class="w-3/5 p-3 font-semibold">
{{ t("shortcodes.url") }}
</div>
<div class="w-1/5 p-3 font-semibold">
{{ t("shortcodes.created_on") }}
</div>
<div class="w-1/5 p-3 font-semibold text-center">
{{ t("shortcodes.actions") }}
</div>
</div>
</div>
<div
class="w-full max-h-sm flex flex-col items-center justify-between overflow-y-scroll rounded lg:rounded-t-none border lg:divide-y border-dividerLight divide-dividerLight"
>
<div
class="flex flex-col h-auto h-full border-r border-dividerLight w-full"
>
<ProfileShortcode
v-for="(shortcode, shortcodeIndex) in myShortcodes"
:key="`shortcode-${shortcodeIndex}`"
:shortcode="shortcode"
@delete-shortcode="deleteShortcode"
/>
<SmartIntersection
v-if="hasMoreShortcodes && myShortcodes.length > 0"
@intersecting="loadMoreShortcodes()"
>
<div
v-if="adapterLoading"
class="flex flex-col items-center py-3"
>
<SmartSpinner />
</div>
</SmartIntersection>
</div>
</div>
</div>
<div
v-if="!loading && adapterError"
class="flex flex-col items-center py-4"
>
<i class="mb-4 material-icons">help_outline</i>
{{ getErrorMessage(adapterError) }}
</div>
</div>
</section>
</div>
</SmartTab>
<SmartTab :id="'teams'" :label="t('team.title')">
<Teams :modal="false" />
@@ -193,12 +292,18 @@ import {
useMeta,
defineComponent,
watchEffect,
computed,
} from "@nuxtjs/composition-api"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { GQLError } from "~/helpers/backend/GQLClient"
import {
currentUser$,
probableUser$,
setDisplayName,
setEmailAddress,
verifyEmailAddress,
onLoggedIn,
} from "~/helpers/fb/auth"
import {
useReadonlyStream,
@@ -206,6 +311,8 @@ import {
useToast,
} from "~/helpers/utils/composables"
import { toggleSetting, useSetting } from "~/newstore/settings"
import ShortcodeListAdapter from "~/helpers/shortcodes/ShortcodeListAdapter"
import { deleteShortcode as backendDeleteShortcode } from "~/helpers/backend/mutations/Shortcode"
type ProfileTabs = "sync" | "teams"
@@ -220,6 +327,13 @@ const SYNC_COLLECTIONS = useSetting("syncCollections")
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
const SYNC_HISTORY = useSetting("syncHistory")
const currentUser = useReadonlyStream(currentUser$, null)
const probableUser = useReadonlyStream(probableUser$, null)
const loadingCurrentUser = computed(() => {
if (!probableUser.value) return false
else if (!currentUser.value) return true
else return false
})
const displayName = ref(currentUser.value?.displayName)
const updatingDisplayName = ref(false)
@@ -273,6 +387,51 @@ const sendEmailVerification = () => {
})
}
const adapter = new ShortcodeListAdapter(true)
const adapterLoading = useReadonlyStream(adapter.loading$, false)
const adapterError = useReadonlyStream(adapter.error$, null)
const myShortcodes = useReadonlyStream(adapter.shortcodes$, [])
const hasMoreShortcodes = useReadonlyStream(adapter.hasMoreShortcodes$, true)
const loading = computed(
() => adapterLoading.value && myShortcodes.value.length === 0
)
onLoggedIn(() => {
adapter.initialize()
})
const deleteShortcode = (codeID: string) => {
pipe(
backendDeleteShortcode(codeID),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
},
() => {
toast.success(`${t("shortcodes.deleted")}`)
}
)
)()
}
const loadMoreShortcodes = () => {
adapter.loadMore()
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "shortcode/not_found":
return t("shortcodes.not_found")
default:
return t("error.something_went_wrong")
}
}
}
useMeta({
title: `${t("navigation.profile")} • Hoppscotch`,
})