Files
openobserve/web/src/components/MenuLink.vue
T
Bhargavandgithub-actions[bot] 2df0932314 feat(enterprise-gating): show locked features in OSS instead of hiding them (#15112)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-12 11:16:15 +00:00

458 lines
17 KiB
Vue

<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<!-- Always wrapped (disabled when silent) so the tile element the rail measures never remounts. -->
<OTooltip :disabled="!tooltipText" :side="tooltipSide" :delay="300">
<!-- Single dynamic root so external links (<a>), internal links (<router-link>)
and submenu-group triggers (<button>, used by ONavGroup) all share the
exact same tile markup and styling — a group tile is literally a MenuLink. -->
<component
:is="rootComponent"
v-bind="{ ...$attrs, ...rootProps }"
:class="rootClass"
@click="onRootClick"
>
<div
class="nav-menu-item-avatar flex w-full flex-col items-center gap-0.5 max-md:flex-row max-md:items-center max-md:gap-3 max-md:px-1"
>
<div
class="icon-wrapper rounded-default relative inline-flex items-center justify-center p-0.5 transition-colors duration-250"
:class="iconClass"
>
<!-- Rail icons are a hair smaller than the md (24px) default. -->
<OIcon v-if="icon" :name="icon" size="md" class="size-5.5!" />
<component v-else-if="hasIconComponent" :is="iconComponent" class="o-icon size-5.5" />
<div
v-if="locked"
class="bg-surface-base absolute -top-1 -right-1 z-1 flex h-3.5 w-3.5 items-center justify-center rounded-full"
>
<OIcon name="lock" size="xs" class="text-warning-600" />
</div>
<div
v-if="badge && badge > 0"
class="menu-badge text-3xs text-text-inverse shadow-error-500/50 border-menu-badge-ring bg-gradient-notification absolute -top-1 -right-2 z-1 flex h-4 min-w-4 animate-pulse items-center justify-center rounded-full border-2 px-1 leading-none font-bold shadow-md"
aria-live="polite"
:aria-label="t('common.notificationsCount', { count: badge })"
>
{{ badge > 99 ? "99+" : badge }}
</div>
<span
v-else-if="paywalled"
:data-test="`menu-link-${link}-lock`"
class="menu-lock text-text-secondary absolute -end-1.5 -top-1 z-1 inline-flex"
aria-hidden="true"
>
<OIcon name="lock" size="xs" class="size-3!" />
</span>
</div>
<div
ref="labelRef"
class="nav-menu-item-label line-clamp-2 w-full text-center text-xs leading-tight tracking-[0.01em] break-normal wrap-normal text-ellipsis [hyphens:none] transition-colors duration-250 max-md:text-start max-md:text-sm"
:class="labelClass"
>
{{ title }}
</div>
</div>
<TooltipSilencer :silent="!tooltipText" />
<span v-if="paywalled" :id="paywallDescriptionId" class="sr-only">{{ paywallText }}</span>
<!-- Submenu affordance: hidden at rest so a group/link-with-subnav tile is
indistinguishable from a plain tile (consistency). It fades in only on
hover, or stays lit while the flyout is open / the section is active. -->
<span
v-if="asTrigger || submenu"
class="absolute end-1 top-3 transition-opacity duration-150 max-md:end-3 max-md:top-1/2 max-md:-translate-y-1/2 max-md:opacity-100"
:class="[
isActive || expanded
? 'text-accent opacity-100'
: 'text-tabs-inactive-text opacity-70 group-hover:opacity-100',
expanded ? 'max-md:rotate-90 max-md:rtl:-rotate-90' : '',
]"
aria-hidden="true"
>
<OIcon name="chevron-right" size="xs" class="max-md:size-4.5!" />
</span>
</component>
<template #content>
<span :data-test="`menu-link-${link}-tooltip`" class="flex flex-col">
<span>{{ tooltipText }}</span>
<span v-if="paywalled">{{ t("menu.trialEndedTitle") }}</span>
</span>
</template>
</OTooltip>
</template>
<script lang="ts">
import { defineComponent, computed, inject, nextTick, ref, useId, watch, type PropType } from "vue";
import { useStore } from "vuex";
import { useRouter, RouterLink } from "vue-router";
import { injectTooltipRootContext } from "reka-ui";
import { useTheme } from "@/composables/useTheme";
import { raw, type I18nText, useI18nTyped } from "@/types/i18n";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useIsTruncated } from "@/lib/overlay/Tooltip/useIsTruncated";
import { RailIndicatorActiveKey } from "@/lib/core/Navbar/ONavbar.types";
// A disabled reka tooltip drops its trigger listeners but keeps a pending hover-open timer; onClose() is the only way to cancel it.
const TooltipSilencer = defineComponent({
name: "MenuLinkTooltipSilencer",
props: {
silent: {
type: Boolean,
default: false,
},
},
setup(props) {
const tooltip = injectTooltipRootContext(null);
watch(
() => props.silent,
(silent) => {
if (silent) tooltip?.onClose();
},
);
return () => null;
},
});
export default defineComponent({
name: "MenuLink",
components: { OIcon, OTooltip, TooltipSilencer },
// Attrs are re-bound onto the tile element by hand: the tooltip wrapper is a fragment.
inheritAttrs: false,
props: {
title: {
type: String as unknown as PropType<I18nText>,
required: true,
},
caption: {
type: String as unknown as PropType<I18nText>,
default: raw(""),
},
link: {
type: String,
default: "#",
},
icon: {
type: String,
default: "",
},
iconComponent: {
type: Object,
default: () => ({}),
},
mini: {
type: Boolean,
default: true,
},
target: {
type: String,
default: "_self",
},
external: {
type: Boolean,
default: false,
},
badge: {
type: Number,
default: 0,
},
// Trigger mode — render as a non-navigating <button> that opens a submenu
// (used by ONavGroup for a group header). In this mode `active` is supplied
// by the parent (the group is active when any child route is active) and
// `expanded` reflects the open flyout for aria + the caret state.
asTrigger: {
type: Boolean,
default: false,
},
active: {
type: Boolean,
default: false,
},
expanded: {
type: Boolean,
default: false,
},
// Show the submenu caret on a normal link (it reveals sub-pages on hover but
// still navigates on click). `asTrigger` implies this for pure groups.
submenu: {
type: Boolean,
default: false,
},
// The destination is behind the trial paywall: muted, locked, still clickable.
paywalled: {
type: Boolean,
default: false,
},
// Present but unreachable — an enterprise/cloud-only item in a build that
// doesn't unlock it (see utils/enterpriseFeatures.ts). Dimmed, inert on
// click, and shows `lockedMessage` as a tooltip instead of navigating.
locked: {
type: Boolean,
default: false,
},
lockedMessage: {
type: String as unknown as PropType<I18nText>,
default: raw(""),
},
},
emits: ["trigger"],
setup(props, { emit }) {
const { t } = useI18nTyped();
const store = useStore();
const router: any = useRouter();
const openWebPage = (url: string) => {
window.open(url, "_blank");
};
// Compute the tile's active state.
// In trigger mode the parent owns the active flag; otherwise we derive it
// from the route. Home (link === '/') needs special handling because every
// path starts with '/' — match by route name "home". Others prefix-match.
const isActive = computed(() => {
if (props.asTrigger) return props.active;
// Explicit override — link+subnav group tiles (e.g. Data) pass `active`
// computed from "is ANY child route active", since their children live
// under different path roots than the tile's own link.
if (props.active) return true;
const route = router.currentRoute.value;
if (props.link === "/") {
return route.name === "home" || route.path === "/" || route.path === "";
}
return route.path.indexOf(props.link) === 0;
});
// Active-state styling is theme-aware: LIGHT keeps the classic white pill
// with primary-coloured text/icon; DARK uses the tinted "selected" pill
// (matching the dashboard-folder selection) with white text/icon — because
// surface-base is black in dark mode, a white pill there would vanish.
const { isDark } = useTheme();
// When the rail draws a single sliding pill (ONavbar provides this), an active
// tile defers its fill (background + coloured left accent) to that pill and
// keeps only its active text colour — the transparent left border stays so
// there is no width shift between states. Falls back to the self-painted pill
// when the indicator isn't active (default `false` off the rail, or before it
// is positioned), so the nav always shows a selection.
const railIndicatorActive = inject(RailIndicatorActiveKey, undefined);
const slideActive = computed(() => Boolean(railIndicatorActive?.value));
const activePillClass = computed(() => {
if (slideActive.value) {
return isDark.value
? "text-tabs-active-text border-s-2 border-transparent"
: "text-accent border-s-2 border-transparent";
}
return isDark.value
? "text-tabs-active-text bg-tabs-active-bg border-s-2 border-accent"
: "text-accent bg-surface-base border-s-2 border-accent";
});
const activeIconClass = computed(() =>
isDark.value ? "text-tabs-active-text!" : "text-accent!",
);
// Light active and hover labels use the darker label token; the icon keeps the accent.
const activeLabelClass = computed(() =>
isDark.value
? "font-semibold text-tabs-active-text!"
: "font-semibold text-nav-label-accent!",
);
const iconClass = computed(() => {
if (props.paywalled) return "text-text-secondary";
return isActive.value
? activeIconClass.value
: "text-tabs-inactive-text group-hover:text-accent";
});
const labelClass = computed(() => {
if (props.paywalled) return "text-text-body font-medium";
return isActive.value
? activeLabelClass.value
: "text-tabs-inactive-text group-hover:text-nav-label-accent font-medium";
});
// aria-current already announces the page, so the name carries no suffix.
const ariaLabel = computed(() => {
const label: string = props.title || t("menu.navigationLink");
if (props.badge && props.badge > 0) {
return t("menu.ariaWithNotifications", { label, count: props.badge });
}
return label;
});
const paywallDescriptionId = useId();
const paywallText = computed(() => t("menu.trialPageNeedsPlan", { page: props.title }));
const labelRef = ref<HTMLElement | null>(null);
const { isTruncated, update: updateTruncated } = useIsTruncated(labelRef, { clamp: true });
// A new label in the same box never resizes it, so the observer would miss a language switch.
watch(
() => props.title,
async () => {
await nextTick();
updateTruncated();
},
);
// A group tile never gets a tooltip: its flyout opens on the same edge and shows the title.
const tooltipText = computed<I18nText | "">(() => {
if (props.asTrigger || props.submenu) return "";
if (props.paywalled) return paywallText.value;
return isTruncated.value ? props.title : "";
});
const tooltipSide = computed(() => (document.documentElement.dir === "rtl" ? "left" : "right"));
// The default prop is an empty object `{}`; only render a custom icon
// component when a real one was passed.
const hasIconComponent = computed(() => {
const c: any = props.iconComponent;
return !!c && (typeof c === "function" || Object.keys(c).length > 0);
});
// Locked wins over everything else. A real `RouterLink` can't be made
// inert via `onRootClick`'s preventDefault — RouterLink's own click
// handler runs before the parent's `@click` fallthrough listener (vue-
// router merges its `navigate` first) — so a plain `div` is the only way
// to guarantee no navigation.
const rootComponent = computed(() =>
props.locked ? "div" : props.external ? "a" : props.asTrigger ? "button" : RouterLink,
);
// Attributes bound to the root, per mode. Kept in one place so the three
// modes can never drift apart visually.
const rootProps = computed<Record<string, any>>(() => {
const common: Record<string, any> = {
"data-test": props.locked
? `menu-link-${props.link}-item-locked`
: `menu-link-${props.link}-item`,
"aria-label": ariaLabel.value,
"data-truncated": isTruncated.value ? "true" : undefined,
};
if (props.locked) {
// A plain `div` carries no native tabindex — add one so keyboard
// users can still reach it (and trigger its click) by Tab.
return { ...common, role: "link", tabindex: 0 };
}
// Set only when present: an explicit undefined would erase the tooltip's own describedby.
if (props.paywalled) {
common["data-paywalled"] = "true";
common["aria-describedby"] = paywallDescriptionId;
}
if (props.external) {
return {
...common,
href: "#",
target: props.target,
"aria-current": isActive.value ? "page" : undefined,
};
}
if (props.asTrigger) {
return {
...common,
type: "button",
"aria-haspopup": "menu",
"aria-expanded": props.expanded,
};
}
return {
...common,
to: {
path: props.link,
query: { org_identifier: store.state.selectedOrganization?.identifier },
},
target: props.target,
"aria-current": isActive.value ? "page" : undefined,
...(props.submenu ? { "aria-haspopup": "menu", "aria-expanded": props.expanded } : {}),
};
});
const rootClass = computed(() => [
"nav-menu-item",
"group relative block [text-decoration:none]! text-inherit shrink-0 mx-1 px-0 py-1 min-h-0 rounded-surface transition-colors duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-chrome-deeper",
// Drawer rows are tap targets, so they clear the 44px touch-target minimum.
"max-md:flex max-md:min-h-11 max-md:items-center max-md:px-2 max-md:py-2",
// Sit above the rail's sliding pill so icon/label stay readable.
slideActive.value ? "z-10" : "",
props.locked
? "cursor-pointer text-tabs-inactive-text border-s-2 border-transparent bg-transparent hover:bg-tabs-hover-bg"
: isActive.value
? activePillClass.value
: "text-tabs-inactive-text border-s-2 border-transparent bg-transparent hover:bg-tabs-hover-bg",
isActive.value ? "nav-menu-item--active" : "",
props.title === "Functions" ? "menu-link-function" : "",
// Reset native <button> chrome so the trigger looks EXACTLY like a link.
// NOTE: do NOT set a background here — the active/inactive branch above owns
// it. `appearance-none` clears the native button background; adding
// `bg-transparent` here would override the active pill and break the
// selected-state highlight. `font:inherit` keeps the label font identical.
props.asTrigger
? "w-full appearance-none border-0 cursor-pointer text-left [font-family:inherit] [font-size:inherit] [line-height:inherit] [letter-spacing:inherit]"
: "",
]);
const onRootClick = (event: MouseEvent) => {
if (props.locked) {
event.preventDefault();
router.push({ name: "enterpriseFeatures" }).catch(() => {});
return;
}
if (props.external) {
event.preventDefault();
openWebPage(props.link);
return;
}
if (props.asTrigger) {
emit("trigger", event);
}
};
return {
t,
store,
router,
openWebPage,
isActive,
ariaLabel,
activePillClass,
activeIconClass,
activeLabelClass,
iconClass,
labelClass,
labelRef,
isTruncated,
tooltipText,
tooltipSide,
paywallDescriptionId,
paywallText,
hasIconComponent,
rootComponent,
rootProps,
rootClass,
onRootClick,
};
},
});
</script>