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:
parent
8758cba109
commit
0a83894e6a
8 changed files with 474 additions and 370 deletions
|
|
@ -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.",
|
||||||
|
|
|
||||||
349
packages/hoppscotch-common/src/components/history/Personal.vue
Normal file
349
packages/hoppscotch-common/src/components/history/Personal.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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")
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue