feat(dashboards): reorder and inline-rename tabs on the dashboard strip
Enable reorderable OTabs on the live tab strip (edit-only) and wire @reorder to optimistically reorder the tab array, then persist via the same updateDashboard path the settings screen uses (TabsSettings.handleDragEnd). Snaps back + refreshes on failure (409 handled). Rename a tab in place via a hover pencil affordance or a double-click on the name. The editor reads as the tab label itself (transparent, inherits the tab text, auto-sizes) and carries no underline of its own — the tab is made active while editing so OTabs' own indicator is the single line beneath it. Enter/blur saves, Escape reverts; persists via the existing editTab helper. The edited tab opts out of drag while focused; the v-for key is fixed to tabId so tabs keep identity and animate on reorder.
This commit is contained in:
parent
c3b20dd2bc
commit
8fb835cf40
|
|
@ -1,4 +1,4 @@
|
|||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
<!-- 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
|
||||
|
|
@ -25,26 +25,68 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
class="max-w-[calc(100%_-_2.5rem)]"
|
||||
v-model="selectedTabId"
|
||||
:align="'left'"
|
||||
:reorderable="canManage"
|
||||
dense
|
||||
mobile-arrows
|
||||
@click.stop
|
||||
@reorder="onReorder"
|
||||
data-test="dashboard-tab-list"
|
||||
>
|
||||
<OTab
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
v-for="tab in tabs"
|
||||
:key="tab.tabId"
|
||||
:name="tab.tabId"
|
||||
:disable-drag="editingTabId === tab.tabId"
|
||||
@click.stop
|
||||
:data-test="`dashboard-tab-${tab.tabId}`"
|
||||
>
|
||||
<div class="flex w-full flex-nowrap justify-between">
|
||||
<span
|
||||
class="w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:title="tab?.name"
|
||||
:data-test="`dashboard-tab-${tab.tabId}-name`"
|
||||
:data-test-tab-name="tab?.name"
|
||||
>{{ tab?.name }}</span
|
||||
>
|
||||
<div class="group flex w-full flex-nowrap items-center gap-1">
|
||||
<!-- Rename in place: double-click the name (or the hover pencil) to edit
|
||||
it. The input reads as the tab label itself — transparent, inherits
|
||||
the tab's text, auto-sizes — and carries NO underline of its own: the
|
||||
tab is active while editing, so OTabs' own active indicator provides
|
||||
the single line beneath it. Enter/blur saves, Escape reverts. Pointer
|
||||
and key events are stopped so they don't reach the tab trigger
|
||||
(select) or Reka's arrow-key roving. -->
|
||||
<input
|
||||
v-if="editingTabId === tab.tabId"
|
||||
ref="renameInputRef"
|
||||
v-model="editingName"
|
||||
type="text"
|
||||
:size="Math.max(editingName.length, 3)"
|
||||
:maxlength="60"
|
||||
class="text-tabs-active-text w-auto min-w-0 bg-transparent px-0.5 text-sm outline-none"
|
||||
:data-test="`dashboard-tab-${tab.tabId}-rename-input`"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
@dblclick.stop
|
||||
@keydown.stop
|
||||
@keydown.enter.prevent="commitRename(tab)"
|
||||
@keydown.esc.prevent="cancelRename"
|
||||
@blur="commitRename(tab)"
|
||||
/>
|
||||
<template v-else>
|
||||
<span
|
||||
class="w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:title="tab?.name"
|
||||
:data-test="`dashboard-tab-${tab.tabId}-name`"
|
||||
:data-test-tab-name="tab?.name"
|
||||
@dblclick="canManage ? startRename(tab) : undefined"
|
||||
>{{ tab?.name }}</span
|
||||
>
|
||||
<!-- Editable affordance: fades in on tab hover; clicking it renames.
|
||||
Kept in flow (reserves its slot) so hovering never shifts tabs. -->
|
||||
<OIcon
|
||||
v-if="canManage"
|
||||
name="edit"
|
||||
size="sm"
|
||||
class="text-text-secondary shrink-0 cursor-pointer opacity-0 transition-opacity duration-150 group-hover:opacity-70"
|
||||
:data-test="`dashboard-tab-${tab.tabId}-rename-btn`"
|
||||
@click.stop="startRename(tab)"
|
||||
@mousedown.stop
|
||||
@dblclick.stop
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</OTab>
|
||||
</OTabs>
|
||||
|
|
@ -76,11 +118,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
|
||||
import OTab from "@/lib/navigation/Tabs/OTab.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { computed, inject, nextTick, ref } from "vue";
|
||||
import { defineComponent } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import AddTab from "@/components/dashboards/tabs/AddTab.vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { editTab, updateDashboard } from "@/utils/commons";
|
||||
import useNotifications from "@/composables/useNotifications";
|
||||
|
||||
export default defineComponent({
|
||||
name: "TabList",
|
||||
|
|
@ -89,6 +136,7 @@ export default defineComponent({
|
|||
OTabs,
|
||||
OTab,
|
||||
OButton,
|
||||
OIcon,
|
||||
OTooltip,
|
||||
},
|
||||
props: {
|
||||
|
|
@ -104,6 +152,14 @@ export default defineComponent({
|
|||
emits: ["refresh"],
|
||||
setup(props, { emit }) {
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
showPositiveNotification,
|
||||
showErrorNotification,
|
||||
showConfictErrorNotificationWithRefreshBtn,
|
||||
} = useNotifications();
|
||||
|
||||
const showAddTabDialog = ref(false);
|
||||
const isHovered = ref(false);
|
||||
|
||||
|
|
@ -114,18 +170,136 @@ export default defineComponent({
|
|||
return props.dashboardData?.tabs ?? [];
|
||||
});
|
||||
|
||||
// Reorder and rename affordances are edit-only — a view-only dashboard shows
|
||||
// no grip and its names aren't editable.
|
||||
const canManage = computed(() => !props.viewOnly);
|
||||
|
||||
const folderId = computed(() => (route.query.folder as string) ?? "default");
|
||||
|
||||
const refreshDashboard = () => {
|
||||
emit("refresh");
|
||||
showAddTabDialog.value = false;
|
||||
};
|
||||
|
||||
// Shared failure handling for both tab operations: surface a 409 with the
|
||||
// refresh CTA, everything else as a plain error, then reload canonical data.
|
||||
const notifyTabFailure = (error: any, failKey: string) => {
|
||||
if (error?.response?.status === 409) {
|
||||
showConfictErrorNotificationWithRefreshBtn(
|
||||
error?.response?.data?.message ?? error?.message ?? t(failKey),
|
||||
);
|
||||
} else {
|
||||
showErrorNotification(error?.message ?? t(failKey));
|
||||
}
|
||||
emit("refresh");
|
||||
};
|
||||
|
||||
// ── Reorder ────────────────────────────────────────────────────────────
|
||||
// OTabs reports the move by tab id (from/to/before); apply it optimistically
|
||||
// to the live tab list so the strip re-renders (and OTabs' FLIP animates the
|
||||
// slide), then persist via the same updateDashboard path the settings screen
|
||||
// uses (TabsSettings.handleDragEnd) — reorder is just a new tab array order.
|
||||
const onReorder = async ({
|
||||
from,
|
||||
to,
|
||||
before,
|
||||
}: {
|
||||
from: string | number;
|
||||
to: string | number;
|
||||
before: boolean;
|
||||
}) => {
|
||||
const list = [...tabs.value];
|
||||
const fromIdx = list.findIndex((tab: any) => tab.tabId === from);
|
||||
const toIdx = list.findIndex((tab: any) => tab.tabId === to);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
|
||||
const [moved] = list.splice(fromIdx, 1);
|
||||
// toIdx was computed on the pre-splice array; recompute against the target.
|
||||
const insertAt = list.findIndex((tab: any) => tab.tabId === to) + (before ? 0 : 1);
|
||||
list.splice(insertAt, 0, moved);
|
||||
|
||||
// Optimistic: mutate the shared tab array in place so the keyed v-for moves
|
||||
// the existing DOM nodes (what the FLIP animation slides).
|
||||
props.dashboardData!.tabs = list;
|
||||
|
||||
try {
|
||||
await updateDashboard(
|
||||
store,
|
||||
store.state.selectedOrganization.identifier,
|
||||
props.dashboardData?.dashboardId,
|
||||
props.dashboardData,
|
||||
folderId.value,
|
||||
);
|
||||
showPositiveNotification(t("dashboard.tabsSettings.dashboardUpdated"));
|
||||
} catch (error: any) {
|
||||
notifyTabFailure(error, "dashboard.tabsSettings.tabReorderFailed");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Inline rename ──────────────────────────────────────────────────────
|
||||
const editingTabId = ref<string | null>(null);
|
||||
const editingName = ref("");
|
||||
const renameInputRef = ref<HTMLInputElement | HTMLInputElement[] | null>(null);
|
||||
|
||||
const startRename = async (tab: any) => {
|
||||
// Activate the tab being renamed so OTabs' active indicator sits under the
|
||||
// input (double-click already selects it; the hover pencil path needs this).
|
||||
selectedTabId.value = tab.tabId;
|
||||
editingTabId.value = tab.tabId;
|
||||
editingName.value = tab?.name ?? "";
|
||||
// Focus (and select) the freshly-mounted input so typing replaces the name.
|
||||
await nextTick();
|
||||
const el = Array.isArray(renameInputRef.value)
|
||||
? renameInputRef.value[0]
|
||||
: renameInputRef.value;
|
||||
el?.focus();
|
||||
el?.select();
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
editingTabId.value = null;
|
||||
editingName.value = "";
|
||||
};
|
||||
|
||||
const commitRename = async (tab: any) => {
|
||||
// Enter closes the field, so the follow-up blur re-enters here with the tab
|
||||
// no longer active — that early-returns, keeping the save single.
|
||||
if (editingTabId.value !== tab.tabId) return;
|
||||
const name = editingName.value.trim();
|
||||
editingTabId.value = null;
|
||||
// Nothing to save: empty or unchanged → keep the old name.
|
||||
if (!name || name === tab?.name) {
|
||||
editingName.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await editTab(store, props.dashboardData?.dashboardId, folderId.value, tab.tabId, { name });
|
||||
emit("refresh");
|
||||
showPositiveNotification(t("dashboard.tabsSettings.tabUpdated"));
|
||||
} catch (error: any) {
|
||||
notifyTabFailure(error, "dashboard.tabsSettings.tabUpdationFailed");
|
||||
} finally {
|
||||
editingName.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
t,
|
||||
showAddTabDialog,
|
||||
refreshDashboard,
|
||||
tabs,
|
||||
route,
|
||||
isHovered,
|
||||
selectedTabId,
|
||||
canManage,
|
||||
onReorder,
|
||||
editingTabId,
|
||||
editingName,
|
||||
renameInputRef,
|
||||
startRename,
|
||||
commitRename,
|
||||
cancelRename,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue