feat: group history by date and time

This commit is contained in:
liyasthomas
2021-12-31 12:39:23 +05:30
parent 2de73ae7b4
commit a0f7201fae
9 changed files with 189 additions and 162 deletions

View File

@@ -102,6 +102,7 @@ body {
.material-icons { .material-icons {
@apply flex-shrink-0; @apply flex-shrink-0;
@apply overflow-hidden;
font-size: var(--line-height-body) !important; font-size: var(--line-height-body) !important;
width: var(--line-height-body); width: var(--line-height-body);
@@ -109,6 +110,7 @@ body {
.svg-icons { .svg-icons {
@apply flex-shrink-0; @apply flex-shrink-0;
@apply overflow-hidden;
height: var(--line-height-body); height: var(--line-height-body);
width: var(--line-height-body); width: var(--line-height-body);
@@ -457,6 +459,10 @@ pre.ace_editor {
@apply px-1; @apply px-1;
} }
.capitalize-first::first-letter {
@apply capitalize;
}
@media (max-width: 767px) { @media (max-width: 767px) {
main { main {
margin-bottom: env(safe-area-inset-bottom); margin-bottom: env(safe-area-inset-bottom);

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="bg-error flex justify-between"> <div class="bg-error flex justify-between">
<span <span
class="flex py-2 px-4 transition justify-center group relative items-center" class="flex py-2 px-4 transition justify-center text-tiny group relative items-center"
> >
<i class="mr-2 material-icons">info_outline</i> <i class="mr-2 material-icons">info_outline</i>
<span class="text-secondaryDark"> <span class="text-secondaryDark">

View File

@@ -9,6 +9,12 @@
<span class="truncate"> <span class="truncate">
{{ entry.request.url }} {{ entry.request.url }}
</span> </span>
<tippy
v-if="entry.updatedOn"
theme="tooltip"
:delay="[500, 20]"
:content="`${new Date(entry.updatedOn).toLocaleString()}`"
/>
</span> </span>
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -49,53 +55,39 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { import { computed, ref } from "@nuxtjs/composition-api"
computed,
defineComponent,
PropType,
ref,
} from "@nuxtjs/composition-api"
import { makeGQLRequest } from "@hoppscotch/data" import { makeGQLRequest } from "@hoppscotch/data"
import { setGQLSession } from "~/newstore/GQLSession" import { setGQLSession } from "~/newstore/GQLSession"
import { GQLHistoryEntry } from "~/newstore/history" import { GQLHistoryEntry } from "~/newstore/history"
export default defineComponent({ const props = defineProps<{
props: { entry: GQLHistoryEntry
entry: { type: Object as PropType<GQLHistoryEntry>, default: () => {} }, showMore: Boolean
showMore: Boolean, }>()
},
setup(props) {
const expand = ref(false)
const query = computed(() => const expand = ref(false)
expand.value
? (props.entry.request.query.split("\n") as string[])
: (props.entry.request.query
.split("\n")
.slice(0, 2)
.concat(["..."]) as string[])
)
const useEntry = () => { const query = computed(() =>
setGQLSession({ expand.value
request: makeGQLRequest({ ? (props.entry.request.query.split("\n") as string[])
name: props.entry.request.name, : (props.entry.request.query
url: props.entry.request.url, .split("\n")
headers: props.entry.request.headers, .slice(0, 2)
query: props.entry.request.query, .concat(["..."]) as string[])
variables: props.entry.request.variables, )
}),
schema: "",
response: props.entry.response,
})
}
return { const useEntry = () => {
expand, setGQLSession({
query, request: makeGQLRequest({
useEntry, name: props.entry.request.name,
} url: props.entry.request.url,
}, headers: props.entry.request.headers,
}) query: props.entry.request.query,
variables: props.entry.request.variables,
}),
schema: "",
response: props.entry.response,
})
}
</script> </script>

View File

@@ -6,14 +6,14 @@
type="search" type="search"
autocomplete="off" autocomplete="off"
class="bg-transparent flex w-full p-4 py-2" class="bg-transparent flex w-full p-4 py-2"
:placeholder="`${$t('action.search')}`" :placeholder="`${t('action.search')}`"
/> />
<div class="flex"> <div class="flex">
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/history" to="https://docs.hoppscotch.io/features/history"
blank blank
:title="$t('app.wiki')" :title="t('app.wiki')"
svg="help-circle" svg="help-circle"
/> />
<ButtonSecondary <ButtonSecondary
@@ -21,30 +21,39 @@
data-testid="clear_history" data-testid="clear_history"
:disabled="history.length === 0" :disabled="history.length === 0"
svg="trash-2" svg="trash-2"
:title="$t('action.clear_all')" :title="t('action.clear_all')"
@click.native="confirmRemove = true" @click.native="confirmRemove = true"
/> />
</div> </div>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<div v-for="(entry, index) in filteredHistory" :key="`entry-${index}`"> <div
<HistoryRestCard v-for="(filteredHistoryGroup, filteredHistoryGroupIndex) in groupByDate(
v-if="page == 'rest'" filteredHistory,
:id="index" 'updatedOn'
:entry="entry" )"
:show-more="showMore" :key="`filteredHistoryGroup-${filteredHistoryGroupIndex}`"
@toggle-star="toggleStar(entry)" class="flex flex-col"
@delete-entry="deleteHistory(entry)" >
@use-entry="useHistory(entry)" <span
/> class="ml-4 capitalize-first px-3 align-start my-2 py-1 text-secondaryLight bg-primaryLight truncate rounded-l-full flex-inline text-tiny"
<HistoryGraphqlCard >
v-if="page == 'graphql'" {{ filteredHistoryGroupIndex }}
:entry="entry" </span>
:show-more="showMore" <div
@toggle-star="toggleStar(entry)" v-for="(entry, index) in filteredHistoryGroup"
@delete-entry="deleteHistory(entry)" :key="`entry-${index}`"
@use-entry="useHistory(entry)" >
/> <component
:is="page == 'rest' ? 'HistoryRestCard' : 'HistoryGraphqlCard'"
:id="index"
:entry="entry"
:show-more="showMore"
@toggle-star="toggleStar(entry)"
@delete-entry="deleteHistory(entry)"
@use-entry="useHistory(entry)"
/>
</div>
</div> </div>
</div> </div>
<div <div
@@ -53,7 +62,7 @@
> >
<i class="opacity-75 pb-2 material-icons">manage_search</i> <i class="opacity-75 pb-2 material-icons">manage_search</i>
<span class="my-2 text-center"> <span class="my-2 text-center">
{{ $t("state.nothing_found") }} "{{ filterText }}" {{ t("state.nothing_found") }} "{{ filterText }}"
</span> </span>
</div> </div>
<div <div
@@ -64,24 +73,29 @@
:src="`/images/states/${$colorMode.value}/history.svg`" :src="`/images/states/${$colorMode.value}/history.svg`"
loading="lazy" loading="lazy"
class="flex-col object-contain object-center h-16 my-4 w-16 inline-flex" class="flex-col object-contain object-center h-16 my-4 w-16 inline-flex"
:alt="`${$t('empty.history')}`" :alt="`${t('empty.history')}`"
/> />
<span class="text-center mb-4"> <span class="text-center mb-4">
{{ $t("empty.history") }} {{ t("empty.history") }}
</span> </span>
</div> </div>
<SmartConfirmModal <SmartConfirmModal
:show="confirmRemove" :show="confirmRemove"
:title="`${$t('confirm.remove_history')}`" :title="`${t('confirm.remove_history')}`"
@hide-modal="confirmRemove = false" @hide-modal="confirmRemove = false"
@resolve="clearHistory" @resolve="clearHistory"
/> />
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent, PropType } from "@nuxtjs/composition-api" import { computed, ref } from "@nuxtjs/composition-api"
import { useReadonlyStream } from "~/helpers/utils/composables" import * as timeago from "timeago.js"
import {
useI18n,
useReadonlyStream,
useToast,
} from "~/helpers/utils/composables"
import { import {
restHistory$, restHistory$,
graphqlHistory$, graphqlHistory$,
@@ -96,64 +110,65 @@ import {
} from "~/newstore/history" } from "~/newstore/history"
import { setRESTRequest } from "~/newstore/RESTSession" import { setRESTRequest } from "~/newstore/RESTSession"
export default defineComponent({ const props = defineProps<{
props: { page: "rest" | "graphql"
page: { type: String as PropType<"rest" | "graphql">, default: null }, }>()
},
setup(props) {
return {
history: useReadonlyStream<RESTHistoryEntry[] | GQLHistoryEntry[]>(
props.page === "rest" ? restHistory$ : graphqlHistory$,
[]
),
}
},
data() {
return {
filterText: "",
showMore: false,
confirmRemove: false,
}
},
computed: {
filteredHistory(): any[] {
const filteringHistory = this.history as Array<
RESTHistoryEntry | GQLHistoryEntry
>
return filteringHistory.filter( const filterText = ref("")
(entry: RESTHistoryEntry | GQLHistoryEntry) => { const showMore = ref(false)
const filterText = this.filterText.toLowerCase() const confirmRemove = ref(false)
return Object.keys(entry).some((key) => { const toast = useToast()
let value = entry[key as keyof typeof entry] const t = useI18n()
if (value) {
value = `${value}` const groupByDate = (array: any[], key: string) => {
return value.toLowerCase().includes(filterText) return array.reduce((rv: any, x: any) => {
} ;(rv[timeago.format(x[key])] = rv[timeago.format(x[key])] || []).push(x)
return false return rv
}) }, {})
}
const history = useReadonlyStream<RESTHistoryEntry[] | GQLHistoryEntry[]>(
props.page === "rest" ? restHistory$ : graphqlHistory$,
[]
)
const filteredHistory = computed(() => {
const filteringHistory = history as any as Array<
RESTHistoryEntry | GQLHistoryEntry
>
return filteringHistory.value.filter(
(entry: RESTHistoryEntry | GQLHistoryEntry) => {
return Object.keys(entry).some((key) => {
let value = entry[key as keyof typeof entry]
if (value) {
value = `${value}`
return value.toLowerCase().includes(filterText.value.toLowerCase())
} }
) return false
}, })
}, }
methods: { )
clearHistory() {
if (this.page === "rest") clearRESTHistory()
else clearGraphqlHistory()
this.$toast.success(`${this.$t("state.history_deleted")}`)
},
useHistory(entry: any) {
if (this.page === "rest") setRESTRequest(entry.request)
},
deleteHistory(entry: any) {
if (this.page === "rest") deleteRESTHistoryEntry(entry)
else deleteGraphqlHistoryEntry(entry)
this.$toast.success(`${this.$t("state.deleted")}`)
},
toggleStar(entry: any) {
if (this.page === "rest") toggleRESTHistoryEntryStar(entry)
else toggleGraphqlHistoryEntryStar(entry)
},
},
}) })
const clearHistory = () => {
if (props.page === "rest") clearRESTHistory()
else clearGraphqlHistory()
toast.success(`${t("state.history_deleted")}`)
}
const useHistory = (entry: any) => {
if (props.page === "rest") setRESTRequest(entry.request)
}
const deleteHistory = (entry: any) => {
if (props.page === "rest") deleteRESTHistoryEntry(entry)
else deleteGraphqlHistoryEntry(entry)
toast.success(`${t("state.deleted")}`)
}
const toggleStar = (entry: any) => {
if (props.page === "rest") toggleRESTHistoryEntryStar(entry)
else toggleGraphqlHistoryEntryStar(entry)
}
</script> </script>

View File

@@ -1,6 +1,7 @@
<template> <template>
<div class="flex items-stretch group"> <div class="flex items-stretch group">
<span <span
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
class="cursor-pointer flex px-2 w-16 items-center justify-center truncate" class="cursor-pointer flex px-2 w-16 items-center justify-center truncate"
:class="entryStatus.className" :class="entryStatus.className"
data-testid="restore_history_entry" data-testid="restore_history_entry"
@@ -12,12 +13,17 @@
<span <span
class="cursor-pointer flex flex-1 min-w-0 py-2 pr-2 transition group-hover:text-secondaryDark" class="cursor-pointer flex flex-1 min-w-0 py-2 pr-2 transition group-hover:text-secondaryDark"
data-testid="restore_history_entry" data-testid="restore_history_entry"
:title="`${duration}`"
@click="$emit('use-entry')" @click="$emit('use-entry')"
> >
<span class="truncate"> <span class="truncate">
{{ entry.request.endpoint }} {{ entry.request.endpoint }}
</span> </span>
<tippy
v-if="entry.updatedOn"
theme="tooltip"
:delay="[500, 20]"
:content="`${new Date(entry.updatedOn).toLocaleString()}`"
/>
</span> </span>
<ButtonSecondary <ButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -40,46 +46,36 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { computed, defineComponent, PropType } from "@nuxtjs/composition-api" import { computed } from "@nuxtjs/composition-api"
import findStatusGroup from "~/helpers/findStatusGroup" import findStatusGroup from "~/helpers/findStatusGroup"
import { useI18n } from "~/helpers/utils/composables" import { useI18n } from "~/helpers/utils/composables"
import { RESTHistoryEntry } from "~/newstore/history" import { RESTHistoryEntry } from "~/newstore/history"
export default defineComponent({ const props = defineProps<{
props: { entry: RESTHistoryEntry
entry: { type: Object as PropType<RESTHistoryEntry>, default: () => {} }, showMore: Boolean
showMore: Boolean, }>()
},
setup(props) {
const t = useI18n()
const duration = computed(() => { const t = useI18n()
if (props.entry.responseMeta.duration) {
const responseDuration = props.entry.responseMeta.duration
if (!responseDuration) return ""
return responseDuration > 0 const duration = computed(() => {
? `${t("request.duration")}: ${responseDuration}ms` if (props.entry.responseMeta.duration) {
: t("error.no_duration") const responseDuration = props.entry.responseMeta.duration
} else return t("error.no_duration") if (!responseDuration) return ""
})
const entryStatus = computed(() => { return responseDuration > 0
const foundStatusGroup = findStatusGroup( ? `${t("request.duration")}: ${responseDuration}ms`
props.entry.responseMeta.statusCode : t("error.no_duration")
) } else return t("error.no_duration")
return ( })
foundStatusGroup || {
className: "",
}
)
})
return { const entryStatus = computed(() => {
duration, const foundStatusGroup = findStatusGroup(props.entry.responseMeta.statusCode)
entryStatus, return (
foundStatusGroup || {
className: "",
} }
}, )
}) })
</script> </script>

View File

@@ -206,6 +206,7 @@ export function initHistory() {
historyRef.forEach((doc) => { historyRef.forEach((doc) => {
const entry = doc.data() const entry = doc.data()
entry.id = doc.id entry.id = doc.id
entry.updatedOn = doc.data().updatedOn.toDate()
history.push(translateToNewRESTHistory(entry)) history.push(translateToNewRESTHistory(entry))
}) })
@@ -227,6 +228,7 @@ export function initHistory() {
historyRef.forEach((doc) => { historyRef.forEach((doc) => {
const entry = doc.data() const entry = doc.data()
entry.id = doc.id entry.id = doc.id
entry.updatedOn = doc.data().updatedOn.toDate()
history.push(translateToNewGQLHistory(entry)) history.push(translateToNewGQLHistory(entry))
}) })

View File

@@ -22,6 +22,8 @@ export type RESTHistoryEntry = {
star: boolean star: boolean
id?: string // For when Firebase Firestore is set id?: string // For when Firebase Firestore is set
updatedOn?: Date
} }
export type GQLHistoryEntry = { export type GQLHistoryEntry = {
@@ -33,6 +35,8 @@ export type GQLHistoryEntry = {
star: boolean star: boolean
id?: string // For when Firestore ID is set id?: string // For when Firestore ID is set
updatedOn?: Date
} }
export function makeRESTHistoryEntry( export function makeRESTHistoryEntry(
@@ -50,6 +54,7 @@ export function makeGQLHistoryEntry(
return { return {
v: 1, v: 1,
...x, ...x,
updatedOn: new Date(),
} }
} }
@@ -61,7 +66,7 @@ export function translateToNewRESTHistory(x: any): RESTHistoryEntry {
const star = x.star ?? false const star = x.star ?? false
const duration = x.duration ?? null const duration = x.duration ?? null
const statusCode = x.status ?? null const statusCode = x.status ?? null
const updatedOn = x.updatedOn ?? null
const obj: RESTHistoryEntry = makeRESTHistoryEntry({ const obj: RESTHistoryEntry = makeRESTHistoryEntry({
request, request,
star, star,
@@ -69,6 +74,7 @@ export function translateToNewRESTHistory(x: any): RESTHistoryEntry {
duration, duration,
statusCode, statusCode,
}, },
updatedOn,
}) })
if (x.id) obj.id = x.id if (x.id) obj.id = x.id
@@ -83,11 +89,13 @@ export function translateToNewGQLHistory(x: any): GQLHistoryEntry {
const request = translateToGQLRequest(x) const request = translateToGQLRequest(x)
const star = x.star ?? false const star = x.star ?? false
const response = x.response ?? "" const response = x.response ?? ""
const updatedOn = x.updatedOn ?? ""
const obj: GQLHistoryEntry = makeGQLHistoryEntry({ const obj: GQLHistoryEntry = makeGQLHistoryEntry({
request, request,
star, star,
response, response,
updatedOn,
}) })
if (x.id) obj.id = x.id if (x.id) obj.id = x.id
@@ -313,6 +321,7 @@ completedRESTResponse$.subscribe((res) => {
statusCode: res.statusCode, statusCode: res.statusCode,
}, },
star: false, star: false,
updatedOn: new Date(),
}) })
) )
} }

View File

@@ -96,6 +96,7 @@
"splitpanes": "^2.3.8", "splitpanes": "^2.3.8",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "^0.11.0",
"tern": "^0.24.3", "tern": "^0.24.3",
"timeago.js": "^4.0.2",
"uuid": "8.3.2", "uuid": "8.3.2",
"vue-apollo": "^3.1.0", "vue-apollo": "^3.1.0",
"vue-functional-data-merge": "^3.1.0", "vue-functional-data-merge": "^3.1.0",

6
pnpm-lock.yaml generated
View File

@@ -156,6 +156,7 @@ importers:
stylelint-config-standard-scss: ^3.0.0 stylelint-config-standard-scss: ^3.0.0
subscriptions-transport-ws: ^0.11.0 subscriptions-transport-ws: ^0.11.0
tern: ^0.24.3 tern: ^0.24.3
timeago.js: ^4.0.2
ts-jest: ^27.1.2 ts-jest: ^27.1.2
typescript: ^4.5.4 typescript: ^4.5.4
uuid: 8.3.2 uuid: 8.3.2
@@ -233,6 +234,7 @@ importers:
splitpanes: 2.3.8 splitpanes: 2.3.8
subscriptions-transport-ws: 0.11.0_graphql@15.7.2 subscriptions-transport-ws: 0.11.0_graphql@15.7.2
tern: 0.24.3 tern: 0.24.3
timeago.js: 4.0.2
uuid: 8.3.2 uuid: 8.3.2
vue-apollo: 3.1.0_graphql-tag@2.12.6 vue-apollo: 3.1.0_graphql-tag@2.12.6
vue-functional-data-merge: 3.1.0 vue-functional-data-merge: 3.1.0
@@ -16684,6 +16686,10 @@ packages:
webpack: 4.46.0 webpack: 4.46.0
dev: false dev: false
/timeago.js/4.0.2:
resolution: {integrity: sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==}
dev: false
/timers-browserify/2.0.12: /timers-browserify/2.0.12:
resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==}
engines: {node: '>=0.6.0'} engines: {node: '>=0.6.0'}