feat(common): introducing history ui provider service to hoppscotch-common (#4706)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Joel Jacob Stephen 2025-01-29 11:37:30 -06:00 committed by GitHub
parent 8758cba109
commit 0a83894e6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 474 additions and 370 deletions

View file

@ -67,6 +67,25 @@
"enable": "Enable", "enable": "Enable",
"disable": "Disable" "disable": "Disable"
}, },
"activity_logs": {
"ACTIVITY_LOG_DELETE": "Activity log has been deleted",
"WORKSPACE_CREATE": "Created new workspace {name}",
"WORKSPACE_RENAME": "Renamed workspace from {old_name} to {new_name}",
"WORKSPACE_USER_ADD": "{user} was added to the workspace as {role}",
"WORKSPACE_USER_INVITE": "{user} was invited by {inviteeEmail} as {role}",
"WORKSPACE_USER_INVITE_REVOKE": "Revoked invitation of {inviteeEmail} as {inviteeRole}",
"WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} accepted the invitation as {inviteeRole}",
"WORKSPACE_USER_REMOVE": "{user} was removed from the workspace",
"WORKSPACE_USER_ROLE_UPDATE": "{user}'s role was updated from {old_role} to {new_role}",
"COLLECTION_CREATE": "Created new collection {title}",
"COLLECTION_RENAME": "Renamed collection from {old_title} to {new_title}",
"COLLECTION_IMPORT": "Imported collection {title}",
"COLLECTION_DELETE": "Deleted collection {title}",
"COLLECTION_DUPLICATE": "Duplicated collection {parentTitle}",
"REQUEST_CREATE": "Created new request {title}",
"REQUEST_RENAME": "Renamed request from {old_title} to {new_title}",
"REQUEST_DELETE": "Deleted request {title}"
},
"add": { "add": {
"new": "Add new", "new": "Add new",
"star": "Add star" "star": "Add star"
@ -263,6 +282,7 @@
"confirm": { "confirm": {
"close_unsaved_tab": "Are you sure you want to close this tab?", "close_unsaved_tab": "Are you sure you want to close this tab?",
"close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.",
"delete_all_activity_logs": "Are you sure you want to delete all activity logs?",
"exit_team": "Are you sure you want to leave this workspace?", "exit_team": "Are you sure you want to leave this workspace?",
"logout": "Are you sure you want to logout?", "logout": "Are you sure you want to logout?",
"remove_collection": "Are you sure you want to permanently delete this collection?", "remove_collection": "Are you sure you want to permanently delete this collection?",
@ -317,6 +337,7 @@
"generate_message": "Import any Hoppscotch collection to generate API documentation on-the-go." "generate_message": "Import any Hoppscotch collection to generate API documentation on-the-go."
}, },
"empty": { "empty": {
"activity_logs": "No activity logs found",
"authorization": "This request does not use any authorization", "authorization": "This request does not use any authorization",
"body": "This request does not have a body", "body": "This request does not have a body",
"collection": "Collection is empty", "collection": "Collection is empty",
@ -395,8 +416,11 @@
"danger_zone": "Danger zone", "danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these workspaces:", "delete_account": "Your account is currently an owner in these workspaces:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these workspaces before you can delete your account.", "delete_account_description": "You must either remove yourself, transfer ownership, or delete these workspaces before you can delete your account.",
"delete_activity_log": "Failed to delete activity log",
"delete_all_activity_logs": "Failed to delete all activity logs",
"empty_profile_name": "Profile name cannot be empty", "empty_profile_name": "Profile name cannot be empty",
"empty_req_name": "Empty Request Name", "empty_req_name": "Empty Request Name",
"fetch_activity_logs": "Failed to fetch activity logs",
"f12_details": "(F12 for details)", "f12_details": "(F12 for details)",
"gql_prettify_invalid_query": "Couldn't prettify an invalid query, solve query syntax errors and try again", "gql_prettify_invalid_query": "Couldn't prettify an invalid query, solve query syntax errors and try again",
"incomplete_config_urls": "Incomplete configuration URLs", "incomplete_config_urls": "Incomplete configuration URLs",
@ -419,6 +443,7 @@
"same_profile_name": "Updated profile name is same as the current profile name", "same_profile_name": "Updated profile name is same as the current profile name",
"script_fail": "Could not execute pre-request script", "script_fail": "Could not execute pre-request script",
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something went wrong",
"subscription_error": "Failed to subscribe to the topic: {error}",
"test_script_fail": "Could not execute post-request script", "test_script_fail": "Could not execute post-request script",
"reading_files": "Error while reading one or more files.", "reading_files": "Error while reading one or more files.",
"fetching_access_tokens_list": "Something went wrong while fetching the list of tokens", "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens",
@ -1096,9 +1121,14 @@
"failed": "Failed" "failed": "Failed"
}, },
"team": { "team": {
"activity_logs": "Activity Logs",
"already_member": "This email is associated with an existing user.", "already_member": "This email is associated with an existing user.",
"create_new": "Create new workspace", "create_new": "Create new workspace",
"deleted": "Workspace deleted", "deleted": "Workspace deleted",
"delete_all_activity_logs": "Delete all activity logs",
"delete_activity_log": "Delete activity log",
"deleted_activity_log": "Deleted selected activity log",
"deleted_all_activity_logs": "Deleted all activity logs",
"edit": "Edit Workspace", "edit": "Edit Workspace",
"email": "E-mail", "email": "E-mail",
"email_do_not_match": "Email doesn't match with your account details. Contact your workspace owner.", "email_do_not_match": "Email doesn't match with your account details. Contact your workspace owner.",

View file

@ -0,0 +1,349 @@
<template>
<div>
<div
class="sticky top-0 z-10 flex flex-shrink-0 flex-col overflow-x-auto border-b border-dividerLight bg-primary"
>
<div class="flex">
<input
v-model="filterText"
type="search"
autocomplete="off"
class="flex w-full bg-transparent px-4 py-2 h-8"
:placeholder="`${t('action.search')}`"
/>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/history"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.filter')"
:icon="IconFilter"
/>
<template #content="{ hide }">
<div ref="tippyActions" class="flex flex-col focus:outline-none">
<div class="pb-2 pl-4 text-tiny text-secondaryLight">
{{ t("action.filter") }}
</div>
<HoppSmartRadioGroup
v-model="filterSelection"
:radios="filters"
@update:model-value="hide()"
/>
<hr />
<div class="pb-2 pl-4 text-tiny text-secondaryLight">
{{ t("action.group_by") }}
</div>
<HoppSmartRadioGroup
v-model="groupSelection"
:radios="groups"
@update:model-value="hide()"
/>
</div>
</template>
</tippy>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
data-testid="clear_history"
:disabled="history.length === 0"
:icon="IconTrash2"
:title="t('action.clear_all')"
@click="confirmRemove = true"
/>
</div>
</div>
</div>
<div class="flex flex-col">
<details
v-for="(
filteredHistoryGroup, filteredHistoryGroupIndex
) in filteredHistoryGroups"
:key="`filteredHistoryGroup-${filteredHistoryGroupIndex}`"
class="flex flex-col"
open
>
<summary
class="group flex min-w-0 flex-1 cursor-pointer items-center justify-between text-tiny text-secondaryLight transition focus:outline-none"
>
<span
class="inline-flex items-center justify-center truncate px-4 py-2 transition group-hover:text-secondary"
>
<icon-lucide-chevron-right
class="indicator mr-2 flex flex-shrink-0"
/>
<span
:class="[
{ 'capitalize-first': groupSelection === 'TIME' },
'truncate',
]"
>
{{ filteredHistoryGroupIndex }}
</span>
</span>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconTrash"
color="red"
:title="t('action.remove')"
class="hidden group-hover:inline-flex"
@click="deleteBatchHistoryEntry(filteredHistoryGroup)"
/>
</summary>
<component
:is="page === 'rest' ? HistoryRestCard : HistoryGraphqlCard"
v-for="(entry, index) in filteredHistoryGroup"
:id="index"
:key="`entry-${index}`"
:entry="entry.entry"
:show-more="showMore"
@toggle-star="toggleStar(entry.entry)"
@delete-entry="deleteHistory(entry.entry)"
@use-entry="useHistory(toRaw(entry.entry))"
@add-to-collection="addToCollection(entry.entry)"
/>
</details>
</div>
<HoppSmartPlaceholder
v-if="history.length === 0"
:src="`/images/states/${colorMode.value}/time.svg`"
:alt="`${t('empty.history')}`"
:text="t('empty.history')"
/>
<HoppSmartPlaceholder
v-else-if="
Object.keys(filteredHistoryGroups).length === 0 ||
filteredHistory.length === 0
"
:text="`${t('state.nothing_found')} ‟${filterText || filterSelection}”`"
>
<template #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
<template #body>
<HoppButtonSecondary
:label="t('action.clear')"
outline
@click="
() => {
filterText = ''
filterSelection = 'ALL'
}
"
/>
</template>
</HoppSmartPlaceholder>
<HoppSmartConfirmModal
:show="confirmRemove"
:title="`${t('confirm.remove_history')}`"
@hide-modal="confirmRemove = false"
@resolve="clearHistory"
/>
</div>
</template>
<script setup lang="ts">
import IconHelpCircle from "~icons/lucide/help-circle"
import IconTrash2 from "~icons/lucide/trash-2"
import IconTrash from "~icons/lucide/trash"
import IconFilter from "~icons/lucide/filter"
import { computed, ref, Ref, toRaw } from "vue"
import { useColorMode } from "@composables/theming"
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { groupBy, escapeRegExp, filter } from "lodash-es"
import { useTimeAgo } from "@vueuse/core"
import { pipe } from "fp-ts/function"
import * as A from "fp-ts/Array"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import {
restHistory$,
graphqlHistory$,
clearRESTHistory,
clearGraphqlHistory,
toggleGraphqlHistoryEntryStar,
toggleRESTHistoryEntryStar,
deleteGraphqlHistoryEntry,
deleteRESTHistoryEntry,
RESTHistoryEntry,
GQLHistoryEntry,
} from "~/newstore/history"
import HistoryRestCard from "./rest/Card.vue"
import HistoryGraphqlCard from "./graphql/Card.vue"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
type TimedHistoryEntry = {
entry: HistoryEntry
timeAgo: Ref<string>
}
const props = defineProps<{
page: "rest" | "graphql"
}>()
const toast = useToast()
const t = useI18n()
const colorMode = useColorMode()
const filterText = ref("")
const showMore = ref(false)
const confirmRemove = ref(false)
const history = useReadonlyStream<RESTHistoryEntry[] | GQLHistoryEntry[]>(
props.page === "rest" ? restHistory$ : graphqlHistory$,
[]
)
const deepCheckForRegex = (value: unknown, regExp: RegExp): boolean => {
if (value === null || value === undefined) return false
if (typeof value === "string") return regExp.test(value)
if (typeof value === "number") return regExp.test(value.toString())
if (typeof value === "object")
return Object.values(value).some((input) =>
deepCheckForRegex(input, regExp)
)
if (Array.isArray(value))
return value.some((input) => deepCheckForRegex(input, regExp))
return false
}
const filteredHistory = computed(() =>
pipe(
history.value as HistoryEntry[],
A.filter(
(
input
): input is HistoryEntry & {
updatedOn: NonNullable<HistoryEntry["updatedOn"]>
} => {
return (
!!input.updatedOn &&
(filterText.value.length === 0 ||
deepCheckForRegex(
input,
new RegExp(escapeRegExp(filterText.value), "gi")
))
)
}
),
A.map(
(entry): TimedHistoryEntry => ({
entry,
timeAgo: useTimeAgo(entry.updatedOn),
})
)
)
)
const filters = computed(() => [
{ value: "ALL" as const, label: t("filter.all") },
{ value: "STARRED" as const, label: t("filter.starred") },
])
type FilterMode = (typeof filters)["value"][number]["value"]
const filterSelection = ref<FilterMode>("ALL")
const groups = computed(() => [
{ value: "TIME" as const, label: t("group.time") },
{ value: "URL" as const, label: t("group.url") },
])
type GroupMode = (typeof groups)["value"][number]["value"]
const groupSelection = ref<GroupMode>("TIME")
const filteredHistoryGroups = computed(() =>
groupBy(
filter(filteredHistory.value, (input) =>
filterSelection.value === "STARRED" ? input.entry.star : true
),
(input) =>
groupSelection.value === "TIME"
? input.timeAgo.value
: getAppropriateURL(input.entry)
)
)
const getAppropriateURL = (entry: HistoryEntry) => {
if (props.page === "rest") {
return (entry.request as HoppRESTRequest).endpoint
} else if (props.page === "graphql") {
return (entry.request as HoppGQLRequest).url
}
}
const clearHistory = () => {
if (props.page === "rest") clearRESTHistory()
else clearGraphqlHistory()
toast.success(`${t("state.history_deleted")}`)
}
// NOTE: For GQL, the HistoryGraphqlCard component already implements useEntry
// (That is not a really good behaviour tho ¯\_()_/¯)
const tabs = useService(RESTTabService)
const useHistory = (entry: RESTHistoryEntry) => {
tabs.createNewTab({
request: entry.request,
isDirty: false,
})
}
const isRESTHistoryEntry = (
entries: TimedHistoryEntry[]
): entries is Array<TimedHistoryEntry & { entry: RESTHistoryEntry }> =>
// If the page is rest, then we can guarantee what we have is a RESTHistoryEnry
props.page === "rest"
const deleteBatchHistoryEntry = (entries: TimedHistoryEntry[]) => {
if (isRESTHistoryEntry(entries)) {
entries.forEach((entry) => {
deleteRESTHistoryEntry(entry.entry)
})
} else {
entries.forEach((entry) => {
deleteGraphqlHistoryEntry(entry.entry as GQLHistoryEntry)
})
}
toast.success(`${t("state.deleted")}`)
}
const deleteHistory = (entry: HistoryEntry) => {
if (props.page === "rest") deleteRESTHistoryEntry(entry as RESTHistoryEntry)
else deleteGraphqlHistoryEntry(entry as GQLHistoryEntry)
toast.success(`${t("state.deleted")}`)
}
const addToCollection = (entry: HistoryEntry) => {
if (props.page === "rest") {
invokeAction("request.save-as", {
requestType: "rest",
request: entry.request as HoppRESTRequest,
})
}
}
const toggleStar = (entry: HistoryEntry) => {
// History entry type specified because function does not know the type
if (props.page === "rest")
toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
}
defineActionHandler("history.clear", () => {
confirmRemove.value = true
})
</script>

View file

@ -1,375 +1,44 @@
<template> <template>
<div> <WorkspaceCurrent :section="section">
<div <template #item>
class="sticky top-0 z-10 flex flex-shrink-0 flex-col overflow-x-auto border-b border-dividerLight bg-primary" <component
> :is="platform.ui?.additionalSidebarHeaderItem"
<WorkspaceCurrent :section="t('tab.history')" :is-only-personal="true" /> v-if="
<div class="flex"> props.selectedTab === 'history' &&
<input historyUIProviderService.isEnabled.value
v-model="filterText" "
type="search" />
autocomplete="off" </template>
class="flex w-full bg-transparent px-4 py-2 h-8" </WorkspaceCurrent>
:placeholder="`${t('action.search')}`" <HistoryPersonal
/> v-if="workspace === 'personal' || !historyUIProviderService.isEnabled.value"
<div class="flex"> :page="page"
<HoppButtonSecondary />
v-tippy="{ theme: 'tooltip' }" <component :is="platform.ui?.additionalHistoryComponent" v-else />
to="https://docs.hoppscotch.io/documentation/features/history"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.filter')"
:icon="IconFilter"
/>
<template #content="{ hide }">
<div ref="tippyActions" class="flex flex-col focus:outline-none">
<div class="pb-2 pl-4 text-tiny text-secondaryLight">
{{ t("action.filter") }}
</div>
<HoppSmartRadioGroup
v-model="filterSelection"
:radios="filters"
@update:model-value="hide()"
/>
<hr />
<div class="pb-2 pl-4 text-tiny text-secondaryLight">
{{ t("action.group_by") }}
</div>
<HoppSmartRadioGroup
v-model="groupSelection"
:radios="groups"
@update:model-value="hide()"
/>
</div>
</template>
</tippy>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
data-testid="clear_history"
:disabled="
history.length === 0 ||
!isHistoryStoreEnabled ||
isFetchingHistoryStoreStatus
"
:icon="IconTrash2"
:title="t('action.clear_all')"
@click="confirmRemove = true"
/>
</div>
</div>
</div>
<div
v-if="isHistoryStoreEnabled && !isFetchingHistoryStoreStatus"
class="flex flex-col"
>
<details
v-for="(
filteredHistoryGroup, filteredHistoryGroupIndex
) in filteredHistoryGroups"
:key="`filteredHistoryGroup-${filteredHistoryGroupIndex}`"
class="flex flex-col"
open
>
<summary
class="group flex min-w-0 flex-1 cursor-pointer items-center justify-between text-tiny text-secondaryLight transition focus:outline-none"
>
<span
class="inline-flex items-center justify-center truncate px-4 py-2 transition group-hover:text-secondary"
>
<icon-lucide-chevron-right
class="indicator mr-2 flex flex-shrink-0"
/>
<span
:class="[
{ 'capitalize-first': groupSelection === 'TIME' },
'truncate',
]"
>
{{ filteredHistoryGroupIndex }}
</span>
</span>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconTrash"
color="red"
:title="t('action.remove')"
class="hidden group-hover:inline-flex"
@click="deleteBatchHistoryEntry(filteredHistoryGroup)"
/>
</summary>
<component
:is="page === 'rest' ? HistoryRestCard : HistoryGraphqlCard"
v-for="(entry, index) in filteredHistoryGroup"
:id="index"
:key="`entry-${index}`"
:entry="entry.entry"
:show-more="showMore"
@toggle-star="toggleStar(entry.entry)"
@delete-entry="deleteHistory(entry.entry)"
@use-entry="useHistory(toRaw(entry.entry))"
@add-to-collection="addToCollection(entry.entry)"
/>
</details>
</div>
<HoppSmartPlaceholder
v-if="!isHistoryStoreEnabled && !isFetchingHistoryStoreStatus"
:src="`/images/states/${colorMode.value}/time.svg`"
:alt="`${t('empty.history')}`"
:text="t('settings.history_disabled')"
/>
<HoppSmartPlaceholder
v-else-if="history.length === 0"
:src="`/images/states/${colorMode.value}/time.svg`"
:alt="`${t('empty.history')}`"
:text="t('empty.history')"
/>
<HoppSmartPlaceholder
v-else-if="
Object.keys(filteredHistoryGroups).length === 0 ||
filteredHistory.length === 0
"
:text="`${t('state.nothing_found')} ‟${filterText || filterSelection}”`"
>
<template #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
<template #body>
<HoppButtonSecondary
:label="t('action.clear')"
outline
@click="
() => {
filterText = ''
filterSelection = 'ALL'
}
"
/>
</template>
</HoppSmartPlaceholder>
<HoppSmartConfirmModal
:show="confirmRemove"
:title="`${t('confirm.remove_history')}`"
@hide-modal="confirmRemove = false"
@resolve="clearHistory"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import IconHelpCircle from "~icons/lucide/help-circle"
import IconTrash2 from "~icons/lucide/trash-2"
import IconTrash from "~icons/lucide/trash"
import IconFilter from "~icons/lucide/filter"
import { computed, ref, Ref, toRaw } from "vue"
import { useColorMode } from "@composables/theming"
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { groupBy, escapeRegExp, filter } from "lodash-es"
import { useTimeAgo } from "@vueuse/core"
import { pipe } from "fp-ts/function"
import * as A from "fp-ts/Array"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import {
restHistory$,
graphqlHistory$,
clearRESTHistory,
clearGraphqlHistory,
toggleGraphqlHistoryEntryStar,
toggleRESTHistoryEntryStar,
deleteGraphqlHistoryEntry,
deleteRESTHistoryEntry,
RESTHistoryEntry,
GQLHistoryEntry,
} from "~/newstore/history"
import HistoryRestCard from "./rest/Card.vue"
import HistoryGraphqlCard from "./graphql/Card.vue"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest" import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
import { platform } from "~/platform" import { platform } from "~/platform"
import { HistoryUIProviderService } from "~/services/history-ui-provider.service"
import { WorkspaceService } from "~/services/workspace.service"
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry const t = useI18n()
type TimedHistoryEntry = {
entry: HistoryEntry
timeAgo: Ref<string>
}
const props = defineProps<{ const props = defineProps<{
page: "rest" | "graphql" page: "rest" | "graphql"
selectedTab: string
}>() }>()
const toast = useToast() const workspaceService = useService(WorkspaceService)
const t = useI18n() const historyUIProviderService = useService(HistoryUIProviderService)
const colorMode = useColorMode()
const filterText = ref("") const workspace = computed(() => workspaceService.currentWorkspace.value.type)
const showMore = ref(false) const section = computed(() =>
const confirmRemove = ref(false) workspace.value === "personal" || !historyUIProviderService.isEnabled.value
? t("tab.history")
const history = useReadonlyStream<RESTHistoryEntry[] | GQLHistoryEntry[]>( : historyUIProviderService.historyUIProviderTitle.value(t)
props.page === "rest" ? restHistory$ : graphqlHistory$,
[]
) )
const { isHistoryStoreEnabled, isFetchingHistoryStoreStatus } =
"requestHistoryStore" in platform.sync.history &&
platform.sync.history.requestHistoryStore
? platform.sync.history.requestHistoryStore
: {
isHistoryStoreEnabled: ref(true),
isFetchingHistoryStoreStatus: ref(false),
}
const deepCheckForRegex = (value: unknown, regExp: RegExp): boolean => {
if (value === null || value === undefined) return false
if (typeof value === "string") return regExp.test(value)
if (typeof value === "number") return regExp.test(value.toString())
if (typeof value === "object")
return Object.values(value).some((input) =>
deepCheckForRegex(input, regExp)
)
if (Array.isArray(value))
return value.some((input) => deepCheckForRegex(input, regExp))
return false
}
const filteredHistory = computed(() =>
pipe(
history.value as HistoryEntry[],
A.filter(
(
input
): input is HistoryEntry & {
updatedOn: NonNullable<HistoryEntry["updatedOn"]>
} => {
return (
!!input.updatedOn &&
(filterText.value.length === 0 ||
deepCheckForRegex(
input,
new RegExp(escapeRegExp(filterText.value), "gi")
))
)
}
),
A.map(
(entry): TimedHistoryEntry => ({
entry,
timeAgo: useTimeAgo(entry.updatedOn),
})
)
)
)
const filters = computed(() => [
{ value: "ALL" as const, label: t("filter.all") },
{ value: "STARRED" as const, label: t("filter.starred") },
])
type FilterMode = (typeof filters)["value"][number]["value"]
const filterSelection = ref<FilterMode>("ALL")
const groups = computed(() => [
{ value: "TIME" as const, label: t("group.time") },
{ value: "URL" as const, label: t("group.url") },
])
type GroupMode = (typeof groups)["value"][number]["value"]
const groupSelection = ref<GroupMode>("TIME")
const filteredHistoryGroups = computed(() =>
groupBy(
filter(filteredHistory.value, (input) =>
filterSelection.value === "STARRED" ? input.entry.star : true
),
(input) =>
groupSelection.value === "TIME"
? input.timeAgo.value
: getAppropriateURL(input.entry)
)
)
const getAppropriateURL = (entry: HistoryEntry) => {
if (props.page === "rest") {
return (entry.request as HoppRESTRequest).endpoint
} else if (props.page === "graphql") {
return (entry.request as HoppGQLRequest).url
}
}
const clearHistory = () => {
if (props.page === "rest") clearRESTHistory()
else clearGraphqlHistory()
toast.success(`${t("state.history_deleted")}`)
}
// NOTE: For GQL, the HistoryGraphqlCard component already implements useEntry
// (That is not a really good behaviour tho ¯\_()_/¯)
const tabs = useService(RESTTabService)
const useHistory = (entry: RESTHistoryEntry) => {
tabs.createNewTab({
type: "request",
request: entry.request,
isDirty: false,
})
}
const isRESTHistoryEntry = (
entries: TimedHistoryEntry[]
): entries is Array<TimedHistoryEntry & { entry: RESTHistoryEntry }> =>
// If the page is rest, then we can guarantee what we have is a RESTHistoryEnry
props.page === "rest"
const deleteBatchHistoryEntry = (entries: TimedHistoryEntry[]) => {
if (isRESTHistoryEntry(entries)) {
entries.forEach((entry) => {
deleteRESTHistoryEntry(entry.entry)
})
} else {
entries.forEach((entry) => {
deleteGraphqlHistoryEntry(entry.entry as GQLHistoryEntry)
})
}
toast.success(`${t("state.deleted")}`)
}
const deleteHistory = (entry: HistoryEntry) => {
if (props.page === "rest") deleteRESTHistoryEntry(entry as RESTHistoryEntry)
else deleteGraphqlHistoryEntry(entry as GQLHistoryEntry)
toast.success(`${t("state.deleted")}`)
}
const addToCollection = (entry: HistoryEntry) => {
if (props.page === "rest") {
invokeAction("request.save-as", {
requestType: "rest",
request: entry.request as HoppRESTRequest,
})
}
}
const toggleStar = (entry: HistoryEntry) => {
// History entry type specified because function does not know the type
if (props.page === "rest")
toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
}
defineActionHandler("history.clear", () => {
confirmRemove.value = true
})
</script> </script>

View file

@ -24,7 +24,7 @@
:icon="IconClock" :icon="IconClock"
:label="`${t('tab.history')}`" :label="`${t('tab.history')}`"
> >
<History :page="'rest'" /> <History :page="'rest'" :selected-tab="selectedNavigationTab" />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
:id="'share-request'" :id="'share-request'"

View file

@ -1,28 +1,31 @@
<template> <template>
<div <div
class="flex items-center overflow-x-auto whitespace-nowrap border-b border-dividerLight px-4 py-2 text-tiny text-secondaryLight" class="flex justify-between border-b border-dividerLight px-4 py-2 text-tiny text-secondaryLight"
> >
<span class="truncate"> <div class="flex items-center overflow-x-auto whitespace-nowrap">
{{ currentWorkspace }} <span class="truncate">
</span> {{ currentWorkspace }}
<icon-lucide-chevron-right v-if="section" class="mx-2" /> </span>
{{ section }} <icon-lucide-chevron-right v-if="section" class="mx-2" />
{{ section }}
</div>
<slot name="item"></slot>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useService } from "dioc/vue"
import { computed } from "vue" import { computed } from "vue"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
import { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service" import { WorkspaceService } from "~/services/workspace.service"
const t = useI18n()
const props = defineProps<{ const props = defineProps<{
section?: string section?: string
isOnlyPersonal?: boolean isOnlyPersonal?: boolean
}>() }>()
const t = useI18n()
const workspaceService = useService(WorkspaceService) const workspaceService = useService(WorkspaceService)
const workspace = workspaceService.currentWorkspace const workspace = workspaceService.currentWorkspace

View file

@ -48,4 +48,14 @@ export type UIPlatformDef = {
* Additional profile Section components in the profile page * Additional profile Section components in the profile page
*/ */
additionalProfileSections?: Component[] additionalProfileSections?: Component[]
/**
* Custom history related components to be shown in the history page
*/
additionalHistoryComponent?: Component
/**
* Custom sidebar header item to be shown in the sidebar header
*/
additionalSidebarHeaderItem?: Component
} }

View file

@ -0,0 +1,25 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it } from "vitest"
import { getI18n } from "~/modules/i18n"
import { HistoryUIProviderService } from "../history-ui-provider.service"
describe("HistoryUIProviderService", () => {
const container = new TestContainer()
const historyUI = container.bind(HistoryUIProviderService)
it("should initialize with default values", () => {
expect(historyUI.isEnabled.value).toBe(false)
})
it("should return correct default title", () => {
const mockT = ((key: string) => key) as ReturnType<typeof getI18n>
const title = historyUI.historyUIProviderTitle.value(mockT)
expect(title).toBe("tab.history")
})
it("should allow toggling enabled state", () => {
expect(historyUI.isEnabled.value).toBe(false)
historyUI.isEnabled.value = true
expect(historyUI.isEnabled.value).toBe(true)
})
})

View file

@ -0,0 +1,18 @@
import { Service } from "dioc"
import { ref } from "vue"
import { getI18n } from "~/modules/i18n"
type HistoryUIProviderTitle = (t: ReturnType<typeof getI18n>) => string
/**
* This service is used to provide custom UI items for the history section.
*/
export class HistoryUIProviderService extends Service {
public static readonly ID = "HISTORY_UI_PROVIDER_SERVICE"
public readonly isEnabled = ref<boolean>(false)
public readonly historyUIProviderTitle = ref<HistoryUIProviderTitle>((t) =>
t("tab.history")
)
}