mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
Merge branch 'fix/oncall-ux-pages' into fix/oncall-ux-polish
# Conflicts: # web/src/views/OnCall/OnCallResponseDetail.vue
This commit is contained in:
@@ -162,4 +162,39 @@ describe("OnCallActivityTimeline", () => {
|
||||
expect(text).toContain("14:37");
|
||||
expect(text).toContain("14:39 · +2m");
|
||||
});
|
||||
/// The agent's paragraph is held to three lines with a way to read the rest; generated lines are not.
|
||||
it("clamps the AI verdict line and leaves the other lines whole", async () => {
|
||||
const verdict: OnCallResponseEvent = {
|
||||
kind: "ai_verdict",
|
||||
at: OPENED + 300_000_000,
|
||||
actor: "o2-sre",
|
||||
body: "The alert fired because the test stream carries synthetic data. ".repeat(8),
|
||||
};
|
||||
const wrapper = mount(OnCallActivityTimeline, {
|
||||
props: { events: [...events, verdict], commentText: "", openedAt: OPENED },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const clamped = wrapper.findAll('[data-test="oncall-clamped-text-body"]');
|
||||
expect(clamped).toHaveLength(1);
|
||||
expect(clamped[0].text()).toContain("synthetic data");
|
||||
expect(clamped[0].classes()).toContain("line-clamp-3");
|
||||
});
|
||||
|
||||
/// The page scrolls past a long feed, so the note box stays stuck to its foot rather than below the fold.
|
||||
it("sticks the note box to the page's foot when pinned", async () => {
|
||||
const wrapper = mount(OnCallActivityTimeline, {
|
||||
props: { events, commentText: "", openedAt: OPENED, pinComposer: true },
|
||||
global: { plugins: [i18n, store] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const composer = wrapper
|
||||
.find('[data-test="oncall-response-note-input"]')
|
||||
.element.closest(".pt-2");
|
||||
expect(composer?.className).toContain("sticky");
|
||||
expect(composer?.className).toContain("bottom-0");
|
||||
expect(composer?.className).toContain("bg-surface-base");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,8 +32,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@pointerout="onPointerOut"
|
||||
>
|
||||
<ActivityTimeline
|
||||
ref="timelineRef"
|
||||
:class="pinComposer ? 'h-[min(40rem,70vh)]!' : ''"
|
||||
:composer-class="pinComposer ? 'bg-surface-base sticky bottom-0 z-10 pb-2' : ''"
|
||||
:events="visibleEvents"
|
||||
:submitting="submitting"
|
||||
:show-scroll-buttons="showScrollButtons"
|
||||
@@ -62,7 +61,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
data-test-comment-input="oncall-response-note-input"
|
||||
data-test-comment-send="oncall-response-note-submit"
|
||||
@submit="$emit('submit')"
|
||||
/>
|
||||
>
|
||||
<template #inline-text="{ event, html }">
|
||||
<OnCallClampedText v-if="CLAMPED_KINDS.includes(event.kind)" :html="html" />
|
||||
<span v-else class="text-text-body text-sm" v-html="html" />
|
||||
</template>
|
||||
</ActivityTimeline>
|
||||
<OTooltip
|
||||
:anchor="personAnchor"
|
||||
:open="!!personAnchor"
|
||||
@@ -73,11 +77,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useStore } from "vuex";
|
||||
import ActivityTimeline from "@/components/shared/ActivityTimeline.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import OnCallClampedText from "@/components/oncall/OnCallClampedText.vue";
|
||||
import { useOnCallClock } from "@/composables/useOnCallClock";
|
||||
import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
import { raw, useI18nTyped, type I18nText, type TranslateFn } from "@/types/i18n";
|
||||
@@ -98,7 +103,7 @@ interface Props {
|
||||
showScrollButtons?: boolean;
|
||||
/** Micros the page opened at, for the "+2m" offset beside each clock time. */
|
||||
openedAt?: number | null;
|
||||
/** Fixes the feed's height so the note box sits at the panel's foot with the newest event above it. */
|
||||
/** Keeps the note box stuck to the scrolling page's foot while the feed runs past it. */
|
||||
pinComposer?: boolean;
|
||||
/** The policy step a rung delay belongs to, or null when the policy no longer has it. */
|
||||
stepOf?: (rungMicros: number) => number | null;
|
||||
@@ -139,6 +144,8 @@ const { nameOf } = useOrgUserNames();
|
||||
const currentUserId = computed(() => nameOf(String(store.state.userInfo?.email ?? "")) || "User");
|
||||
|
||||
const HUMAN_KINDS: ResponseEventKind[] = ["ack", "handoff", "note"];
|
||||
/// The agent's free text runs to a paragraph; every other line is one generated sentence.
|
||||
const CLAMPED_KINDS: ResponseEventKind[] = ["ai_verdict", "rca"];
|
||||
|
||||
const visibleEvents = computed(() => {
|
||||
const merged = mergeTimelineEvents(props.events);
|
||||
@@ -154,19 +161,6 @@ const getAvatarColor = getActivityAvatarColor;
|
||||
const formatRelativeTime = (at: number): string =>
|
||||
timelineStamp(at, props.openedAt, nowMicros.value, store.state.timezone);
|
||||
|
||||
const timelineRef = ref<{ scrollToBottom: () => void } | null>(null);
|
||||
|
||||
// Chat order: the newest line sits just above the note box, so a pinned feed opens scrolled to it.
|
||||
watch(
|
||||
() => visibleEvents.value.length,
|
||||
async () => {
|
||||
if (!props.pinComposer) return;
|
||||
await nextTick();
|
||||
timelineRef.value?.scrollToBottom();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const getEventIcon = (event: OnCallResponseEvent): string => {
|
||||
switch (event.kind) {
|
||||
case "page":
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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/>.
|
||||
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import OnCallClampedText from "@/components/oncall/OnCallClampedText.vue";
|
||||
import i18n from "@/locales";
|
||||
|
||||
const proto = HTMLElement.prototype;
|
||||
const original = {
|
||||
scrollHeight: Object.getOwnPropertyDescriptor(proto, "scrollHeight"),
|
||||
clientHeight: Object.getOwnPropertyDescriptor(proto, "clientHeight"),
|
||||
};
|
||||
|
||||
function sized(scrollHeight: number, clientHeight: number) {
|
||||
Object.defineProperty(proto, "scrollHeight", { configurable: true, get: () => scrollHeight });
|
||||
Object.defineProperty(proto, "clientHeight", { configurable: true, get: () => clientHeight });
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const wrapper = mount(OnCallClampedText, {
|
||||
props: { html: "The alert fired because <b>the stream</b> is synthetic." },
|
||||
global: { plugins: [i18n] },
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("OnCallClampedText", () => {
|
||||
afterEach(() => {
|
||||
for (const [key, desc] of Object.entries(original)) {
|
||||
if (desc) Object.defineProperty(proto, key, desc);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers no toggle when the text fits in three lines", async () => {
|
||||
sized(60, 60);
|
||||
const wrapper = await render();
|
||||
|
||||
expect(wrapper.find('[data-test="oncall-clamped-text-toggle"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-test="oncall-clamped-text-body"]').classes()).toContain(
|
||||
"line-clamp-3",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the full text and closes it again", async () => {
|
||||
sized(200, 60);
|
||||
const wrapper = await render();
|
||||
const toggle = () => wrapper.find('[data-test="oncall-clamped-text-toggle"]');
|
||||
const body = () => wrapper.find('[data-test="oncall-clamped-text-body"]');
|
||||
|
||||
expect(toggle().text()).toBe("Show more");
|
||||
await toggle().trigger("click");
|
||||
expect(body().classes()).not.toContain("line-clamp-3");
|
||||
expect(toggle().text()).toBe("Show less");
|
||||
await toggle().trigger("click");
|
||||
expect(body().classes()).toContain("line-clamp-3");
|
||||
expect(body().html()).toContain("<b>the stream</b>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
<!-- 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/>.
|
||||
-->
|
||||
|
||||
<!-- A long timeline line held to three lines, with the rest one click away. -->
|
||||
<template>
|
||||
<span class="flex basis-full flex-col items-start gap-0.5" data-test="oncall-clamped-text">
|
||||
<span
|
||||
ref="textRef"
|
||||
class="text-text-body text-sm"
|
||||
:class="expanded ? '' : 'line-clamp-3'"
|
||||
data-test="oncall-clamped-text-body"
|
||||
v-html="html"
|
||||
/>
|
||||
<OButton
|
||||
v-if="overflows || expanded"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:icon-left="expanded ? 'expand-less' : 'expand-more'"
|
||||
data-test="oncall-clamped-text-toggle"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
{{ expanded ? t("oncall.activityShowLess") : t("oncall.activityShowMore") }}
|
||||
</OButton>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Already sanitised markup. */
|
||||
html: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const textRef = ref<HTMLElement | null>(null);
|
||||
const expanded = ref(false);
|
||||
const overflows = ref(false);
|
||||
|
||||
/// Measured only while clamped: an expanded line always fits, which would hide the way back.
|
||||
function measure() {
|
||||
const el = textRef.value;
|
||||
if (!el || expanded.value) return;
|
||||
overflows.value = el.scrollHeight > el.clientHeight + 1;
|
||||
}
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
onMounted(() => {
|
||||
measure();
|
||||
if (typeof ResizeObserver !== "undefined" && textRef.value) {
|
||||
observer = new ResizeObserver(measure);
|
||||
observer.observe(textRef.value);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
watch(
|
||||
() => props.html,
|
||||
async () => {
|
||||
await nextTick();
|
||||
measure();
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({ measure });
|
||||
</script>
|
||||
@@ -592,6 +592,97 @@ describe("OnCallMyDeliveries", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("needs you pager", () => {
|
||||
const open = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
delivery({ event_id: `ev_${i}`, response_id: `resp_${i}`, at: AT + i }),
|
||||
);
|
||||
const needsYou = (wrapper: ReturnType<typeof render>) =>
|
||||
wrapper.findComponent('[data-test="oncall-my-needs-you-table"]');
|
||||
|
||||
it("hides the pager while every open page fits", async () => {
|
||||
answer(open(20));
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
expect(needsYou(wrapper).attributes("pagination")).toBe("none");
|
||||
});
|
||||
|
||||
it("pages the open pages once they outgrow one page", async () => {
|
||||
answer(open(21));
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
expect(needsYou(wrapper).attributes("pagination")).toBe("client");
|
||||
expect(needsYou(wrapper).props("pageSize")).toBe(20);
|
||||
});
|
||||
|
||||
it("keeps the chosen page size for the next visit", async () => {
|
||||
answer(open(21));
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
needsYou(wrapper).vm.$emit("update:pageSize", 50);
|
||||
await flushPromises();
|
||||
|
||||
const saved = useListViewState("oncall-my-deliveries", {}) as Record<string, unknown>;
|
||||
expect(saved.openPageSize).toBe(50);
|
||||
});
|
||||
|
||||
it("pages through the open pages in the real table", async () => {
|
||||
answer(open(25));
|
||||
const { OTable: _table, ...rest } = stubs;
|
||||
const wrapper = mount(OnCallMyDeliveries, {
|
||||
global: { plugins: [i18n, store], stubs: rest },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const card = wrapper.find('[data-test="oncall-my-needs-you"]');
|
||||
expect(card.find('[data-test="o2-table-pagination-bottom"]').exists()).toBe(true);
|
||||
expect(card.find('[data-test="o2-table-pagination-info"]').text()).toMatch(/1\D+20\D+25/);
|
||||
await card.find('[data-test="o2-table-next-page-btn"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(card.find('[data-test="o2-table-pagination-info"]').text()).toMatch(/21\D+25\D+25/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("history pager in the real table", () => {
|
||||
const sent = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
delivery({
|
||||
event_id: `ev_${i}`,
|
||||
response_id: `resp_${i}`,
|
||||
title: `alert ${i}`,
|
||||
response_state: "resolved",
|
||||
at: AT + i,
|
||||
}),
|
||||
);
|
||||
const { OTable: _table, ...realTable } = stubs;
|
||||
const card = (wrapper: ReturnType<typeof render>) =>
|
||||
wrapper.find('[data-test="oncall-my-history"]');
|
||||
const rendered = (wrapper: ReturnType<typeof render>) =>
|
||||
card(wrapper).findAll('[data-test^="o2-table-row-"]:not([data-test$="drag-handle"])');
|
||||
|
||||
/// The pager arrives with the rows, so the table must not keep the row model it was created with.
|
||||
it("renders one page of rows that matches the footer", async () => {
|
||||
Object.assign(useListViewState("oncall-my-deliveries", {}), { pageSize: 10 });
|
||||
answer(sent(25));
|
||||
const wrapper = mount(OnCallMyDeliveries, {
|
||||
global: { plugins: [i18n, store], stubs: realTable },
|
||||
});
|
||||
await flushPromises();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await flushPromises();
|
||||
|
||||
expect(rendered(wrapper)).toHaveLength(10);
|
||||
const info = () => card(wrapper).find('[data-test="o2-table-pagination-info"]').text();
|
||||
expect(info()).toMatch(/1\D+10\D+25/);
|
||||
await card(wrapper).find('[data-test="o2-table-next-page-btn"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(rendered(wrapper)).toHaveLength(10);
|
||||
expect(info()).toMatch(/11\D+20\D+25/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
const defaults = { unreadOnly: false, currentPage: 1, pageSize: 20 };
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- What has been sent to the reader: the open pages that still need them, then the history grouped by alert. -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-5" data-test="oncall-my-deliveries">
|
||||
<div
|
||||
class="flex min-h-0 flex-col gap-5 max-xl:flex-none xl:flex-1"
|
||||
data-test="oncall-my-deliveries"
|
||||
>
|
||||
<OEmptyState
|
||||
v-if="loadError !== null"
|
||||
size="inline"
|
||||
@@ -31,22 +34,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template v-else>
|
||||
<section
|
||||
v-if="openRows.length"
|
||||
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border"
|
||||
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border xl:max-h-1/2 xl:min-h-0"
|
||||
data-test="oncall-my-needs-you"
|
||||
>
|
||||
<div class="flex min-h-12 items-center px-4 py-2">
|
||||
<div class="flex min-h-12 shrink-0 items-center px-4 py-2">
|
||||
<OText variant="card-title" as="h2">{{ t("oncall.myNeedsYouTitle") }}</OText>
|
||||
</div>
|
||||
<!-- Basis auto, not 0: the card is sized by its rows up to its cap, so a zero basis would collapse it. -->
|
||||
<!-- Keyed on the mode: OTable fixes its pagination row model when it is created, so a flip needs a fresh table. -->
|
||||
<OTable
|
||||
:key="openPaged ? 'paged' : 'flat'"
|
||||
class="xl:min-h-0 xl:flex-auto"
|
||||
:data="openRows"
|
||||
:columns="openColumns"
|
||||
row-key="response_id"
|
||||
:frame="false"
|
||||
:show-global-filter="false"
|
||||
pagination="none"
|
||||
:pagination="openPaged ? 'client' : 'none'"
|
||||
:page-size="openPageSize"
|
||||
:current-page="openPage"
|
||||
table-id="oncall-my-needs-you"
|
||||
data-test="oncall-my-needs-you-table"
|
||||
@row-click="openPage"
|
||||
@row-click="openResponse"
|
||||
@update:current-page="openPage = $event"
|
||||
@update:page-size="openPageSize = $event"
|
||||
>
|
||||
<template #cell-priority="{ row }">
|
||||
<OTag type="alertPriority" :value="`p${row.priority}`" size="sm" />
|
||||
@@ -95,10 +106,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border"
|
||||
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border xl:min-h-0 xl:flex-1"
|
||||
data-test="oncall-my-history"
|
||||
>
|
||||
<div class="flex min-h-12 flex-wrap items-center gap-2 px-4 py-2">
|
||||
<div class="flex min-h-12 shrink-0 flex-wrap items-center gap-2 px-4 py-2">
|
||||
<OText variant="card-title" as="h2">{{ t("oncall.myHistoryTitle") }}</OText>
|
||||
<OTag
|
||||
v-if="unread"
|
||||
@@ -137,19 +148,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OTable
|
||||
v-if="historyRows.length || loading"
|
||||
ref="oTableRef"
|
||||
:key="historyPaged ? 'paged' : 'flat'"
|
||||
class="xl:min-h-0 xl:flex-1"
|
||||
:data="historyRows"
|
||||
:columns="historyColumns"
|
||||
row-key="key"
|
||||
:frame="false"
|
||||
:loading="loading"
|
||||
:skeleton-rows="HISTORY_SKELETON_ROWS"
|
||||
:pagination="historyRows.length > pageSize ? 'client' : 'none'"
|
||||
:pagination="historyPaged ? 'client' : 'none'"
|
||||
:page-size="pageSize"
|
||||
:current-page="currentPage"
|
||||
:show-global-filter="false"
|
||||
table-id="oncall-my-deliveries"
|
||||
data-test="oncall-my-deliveries-table"
|
||||
@row-click="(row: HistoryRow) => openPage(row.latest)"
|
||||
@row-click="(row: HistoryRow) => openResponse(row.latest)"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
>
|
||||
@@ -336,6 +349,8 @@ const emit = defineEmits<{ unread: [count: number]; "test-page": []; "join-team"
|
||||
|
||||
/// A first visit has a handful of pages, so more skeleton rows would only collapse when they land.
|
||||
const HISTORY_SKELETON_ROWS = 3;
|
||||
/// OTable's smallest page-size option.
|
||||
const SMALLEST_PAGE = 20;
|
||||
|
||||
const CHANNEL_ICON: Record<Channel, IconName> = {
|
||||
email: "mail",
|
||||
@@ -372,20 +387,24 @@ const savedView = useListViewState("oncall-my-deliveries", {
|
||||
unreadOnly: false,
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
openPageSize: 20,
|
||||
});
|
||||
const unreadOnly = ref(savedView.unreadOnly);
|
||||
const currentPage = ref(savedView.currentPage);
|
||||
const pageSize = ref(savedView.pageSize);
|
||||
const oTableRef = ref<any>(null);
|
||||
const openPage = ref(1);
|
||||
const openPageSize = ref(savedView.openPageSize);
|
||||
|
||||
watch(unreadOnly, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
watch([unreadOnly, currentPage, pageSize], () => {
|
||||
watch([unreadOnly, currentPage, pageSize, openPageSize], () => {
|
||||
Object.assign(savedView, {
|
||||
unreadOnly: unreadOnly.value,
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
openPageSize: openPageSize.value,
|
||||
});
|
||||
});
|
||||
// The inbox loads after mount and TanStack resets to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
|
||||
@@ -465,6 +484,11 @@ const historyRows = computed<HistoryRow[]>(() => {
|
||||
.sort((a, b) => b.latest.at - a.latest.at);
|
||||
});
|
||||
|
||||
/// The pager shows once a second page exists, and stays while a larger size is chosen so it can be undone.
|
||||
const paged = (rows: number, size: number) => rows > Math.min(size, SMALLEST_PAGE);
|
||||
const openPaged = computed(() => paged(openRows.value.length, openPageSize.value));
|
||||
const historyPaged = computed(() => paged(historyRows.value.length, pageSize.value));
|
||||
|
||||
const openColumns = computed<OTableColumnDef<MyDelivery>[]>(() => [
|
||||
{ id: "priority", header: t("oncall.priority"), size: 72, sortable: false },
|
||||
{
|
||||
@@ -627,7 +651,7 @@ async function acknowledge(row: MyDelivery) {
|
||||
}
|
||||
}
|
||||
|
||||
function openPage(row: MyDelivery) {
|
||||
function openResponse(row: MyDelivery) {
|
||||
router.push({
|
||||
name: "onCallResponseDetail",
|
||||
params: { responseId: row.response_id },
|
||||
|
||||
@@ -114,11 +114,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="'text-text-body'"
|
||||
v-html="getInlineHtml(event)"
|
||||
></span>
|
||||
<slot name="inline-text" :event="event" :html="getInlineHtml(event)">
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="'text-text-body'"
|
||||
v-html="getInlineHtml(event)"
|
||||
></span>
|
||||
</slot>
|
||||
|
||||
<span
|
||||
v-if="getEventLayout(event) === 'after'"
|
||||
@@ -185,7 +187,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
|
||||
<!-- Comment Input -->
|
||||
<div class="pt-2">
|
||||
<div class="pt-2" :class="composerClass">
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<div
|
||||
@@ -276,6 +278,8 @@ interface Props {
|
||||
dataTestScrollBottom: string;
|
||||
dataTestCommentInput: string;
|
||||
dataTestCommentSend: string;
|
||||
/** Extra classes on the note box, e.g. to keep it stuck to a scrolling page's foot. */
|
||||
composerClass?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -286,6 +290,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isAiLabelled: undefined,
|
||||
aiSreBadgeText: () => raw(""),
|
||||
getTooltip: undefined,
|
||||
composerClass: "",
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "تمت إضافة الملاحظة",
|
||||
"addNoteFailed": "تعذر إضافة الملاحظة",
|
||||
"activitySendNote": "إرسال ملاحظة",
|
||||
"activityShowMore": "عرض المزيد",
|
||||
"activityShowLess": "عرض أقل",
|
||||
"activityScrollToTop": "التمرير إلى الأعلى",
|
||||
"activityScrollToBottom": "التمرير إلى الأسفل",
|
||||
"activityNotedPrefix": "دوّن",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "Notiz hinzugefügt",
|
||||
"addNoteFailed": "Die Notiz konnte nicht hinzugefügt werden.",
|
||||
"activitySendNote": "Notiz senden",
|
||||
"activityShowMore": "Mehr anzeigen",
|
||||
"activityShowLess": "Weniger anzeigen",
|
||||
"activityScrollToTop": "Nach oben scrollen",
|
||||
"activityScrollToBottom": "Nach unten scrollen",
|
||||
"activityNotedPrefix": "notiert",
|
||||
|
||||
@@ -26167,6 +26167,8 @@
|
||||
"noteAdded": "Note added",
|
||||
"addNoteFailed": "Could not add the note",
|
||||
"activitySendNote": "Send note",
|
||||
"activityShowMore": "Show more",
|
||||
"activityShowLess": "Show less",
|
||||
"activityScrollToTop": "Scroll to top",
|
||||
"activityScrollToBottom": "Scroll to bottom",
|
||||
"activityNotedPrefix": "noted",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "Nota añadida",
|
||||
"addNoteFailed": "No se pudo añadir la nota",
|
||||
"activitySendNote": "Enviar nota",
|
||||
"activityShowMore": "Mostrar más",
|
||||
"activityShowLess": "Mostrar menos",
|
||||
"activityScrollToTop": "Ir al principio",
|
||||
"activityScrollToBottom": "Ir al final",
|
||||
"activityNotedPrefix": "anotó",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "Note ajoutée",
|
||||
"addNoteFailed": "Impossible d'ajouter la note",
|
||||
"activitySendNote": "Envoyer la note",
|
||||
"activityShowMore": "Afficher plus",
|
||||
"activityShowLess": "Afficher moins",
|
||||
"activityScrollToTop": "Remonter en haut",
|
||||
"activityScrollToBottom": "Descendre en bas",
|
||||
"activityNotedPrefix": "a noté",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "Nota aggiunta",
|
||||
"addNoteFailed": "Impossibile aggiungere la nota",
|
||||
"activitySendNote": "Invia nota",
|
||||
"activityShowMore": "Mostra di più",
|
||||
"activityShowLess": "Mostra di meno",
|
||||
"activityScrollToTop": "Scorri in alto",
|
||||
"activityScrollToBottom": "Scorri in basso",
|
||||
"activityNotedPrefix": "ha annotato",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "メモを追加しました",
|
||||
"addNoteFailed": "メモを追加できませんでした",
|
||||
"activitySendNote": "メモを送信",
|
||||
"activityShowMore": "さらに表示",
|
||||
"activityShowLess": "表示を減らす",
|
||||
"activityScrollToTop": "最上部へスクロール",
|
||||
"activityScrollToBottom": "最下部へスクロール",
|
||||
"activityNotedPrefix": "メモを追加しました",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "메모가 추가되었습니다",
|
||||
"addNoteFailed": "메모를 추가하지 못했습니다",
|
||||
"activitySendNote": "메모 보내기",
|
||||
"activityShowMore": "더 보기",
|
||||
"activityShowLess": "간단히 보기",
|
||||
"activityScrollToTop": "맨 위로 이동",
|
||||
"activityScrollToBottom": "맨 아래로 이동",
|
||||
"activityNotedPrefix": "메모 남김",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "Notitie toegevoegd",
|
||||
"addNoteFailed": "Kan de notitie niet toevoegen",
|
||||
"activitySendNote": "Notitie verzenden",
|
||||
"activityShowMore": "Meer weergeven",
|
||||
"activityShowLess": "Minder weergeven",
|
||||
"activityScrollToTop": "Scroll naar boven",
|
||||
"activityScrollToBottom": "Scroll naar beneden",
|
||||
"activityNotedPrefix": "noteerde",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "Dodano notatkę",
|
||||
"addNoteFailed": "Nie udało się dodać notatki",
|
||||
"activitySendNote": "Wyślij notatkę",
|
||||
"activityShowMore": "Pokaż więcej",
|
||||
"activityShowLess": "Pokaż mniej",
|
||||
"activityScrollToTop": "Przewiń do góry",
|
||||
"activityScrollToBottom": "Przewiń na dół",
|
||||
"activityNotedPrefix": "dodał notatkę",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "Nota adicionada",
|
||||
"addNoteFailed": "Não foi possível adicionar a nota",
|
||||
"activitySendNote": "Enviar nota",
|
||||
"activityShowMore": "Mostrar mais",
|
||||
"activityShowLess": "Mostrar menos",
|
||||
"activityScrollToTop": "Deslocar para o topo",
|
||||
"activityScrollToBottom": "Deslocar para o fim",
|
||||
"activityNotedPrefix": "anotou",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "Заметка добавлена",
|
||||
"addNoteFailed": "Не удалось добавить заметку",
|
||||
"activitySendNote": "Отправить заметку",
|
||||
"activityShowMore": "Показать больше",
|
||||
"activityShowLess": "Показать меньше",
|
||||
"activityScrollToTop": "Прокрутить вверх",
|
||||
"activityScrollToBottom": "Прокрутить вниз",
|
||||
"activityNotedPrefix": "отметил",
|
||||
|
||||
@@ -26080,6 +26080,8 @@
|
||||
"noteAdded": "Note added",
|
||||
"addNoteFailed": "Could not add the note",
|
||||
"activitySendNote": "Send note",
|
||||
"activityShowMore": "แสดงเพิ่มเติม",
|
||||
"activityShowLess": "แสดงน้อยลง",
|
||||
"activityScrollToTop": "Scroll to top",
|
||||
"activityScrollToBottom": "Scroll to bottom",
|
||||
"activityNotedPrefix": "noted",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "Not eklendi",
|
||||
"addNoteFailed": "Not eklenemedi",
|
||||
"activitySendNote": "Notu gönder",
|
||||
"activityShowMore": "Daha fazla göster",
|
||||
"activityShowLess": "Daha az göster",
|
||||
"activityScrollToTop": "En üste git",
|
||||
"activityScrollToBottom": "En alta git",
|
||||
"activityNotedPrefix": "not ekledi",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "Đã thêm ghi chú",
|
||||
"addNoteFailed": "Không thể thêm ghi chú",
|
||||
"activitySendNote": "Gửi ghi chú",
|
||||
"activityShowMore": "Xem thêm",
|
||||
"activityShowLess": "Thu gọn",
|
||||
"activityScrollToTop": "Cuộn lên đầu",
|
||||
"activityScrollToBottom": "Cuộn xuống cuối",
|
||||
"activityNotedPrefix": "đã ghi chú",
|
||||
|
||||
@@ -26082,6 +26082,8 @@
|
||||
"noteAdded": "备注已添加",
|
||||
"addNoteFailed": "无法添加备注",
|
||||
"activitySendNote": "发送备注",
|
||||
"activityShowMore": "显示更多",
|
||||
"activityShowLess": "收起",
|
||||
"activityScrollToTop": "滚动到顶部",
|
||||
"activityScrollToBottom": "滚动到底部",
|
||||
"activityNotedPrefix": "已备注",
|
||||
|
||||
@@ -26077,6 +26077,8 @@
|
||||
"noteAdded": "備註已新增",
|
||||
"addNoteFailed": "無法新增備註",
|
||||
"activitySendNote": "傳送備註",
|
||||
"activityShowMore": "顯示更多",
|
||||
"activityShowLess": "收合",
|
||||
"activityScrollToTop": "捲動至頂端",
|
||||
"activityScrollToBottom": "捲動至底部",
|
||||
"activityNotedPrefix": "已備註",
|
||||
|
||||
@@ -16,13 +16,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- The reader's own on-call: their shifts, how pages reach them, and what has been sent to them. -->
|
||||
<template>
|
||||
<OPageLayout
|
||||
bleed
|
||||
data-test="oncall-mine-page"
|
||||
:title="t('oncall.mineTitle')"
|
||||
icon="person"
|
||||
scroll
|
||||
>
|
||||
<OPageLayout bleed data-test="oncall-mine-page" :title="t('oncall.mineTitle')" icon="person">
|
||||
<template #title-trail>
|
||||
<BetaBadge class="shrink-0" />
|
||||
</template>
|
||||
@@ -61,9 +55,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
data-test="oncall-mine-unavailable"
|
||||
/>
|
||||
|
||||
<OContent v-else y class="flex flex-col gap-5">
|
||||
<OnCallSetupBanner ref="setupRef" />
|
||||
<div class="grid grid-cols-1 items-stretch gap-5 xl:grid-cols-2">
|
||||
<!-- Below xl the two top cards stack and leave no room to share, so the body scrolls instead. -->
|
||||
<OContent
|
||||
v-else
|
||||
y
|
||||
class="flex min-h-0 flex-1 flex-col gap-5 max-xl:overflow-y-auto"
|
||||
data-test="oncall-mine-body"
|
||||
>
|
||||
<OnCallSetupBanner ref="setupRef" class="shrink-0" />
|
||||
<div class="grid shrink-0 grid-cols-1 items-stretch gap-5 xl:grid-cols-2">
|
||||
<!-- The shift first: "am I on call, and until when" is the question this page opens on. -->
|
||||
<section
|
||||
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border"
|
||||
|
||||
@@ -109,7 +109,7 @@ const stubs = {
|
||||
// `title-trail` carries the state tags and `subtitle` the team/firing/
|
||||
// opened line, both of which are assertions of their own.
|
||||
template:
|
||||
"<div><slot name='subtitle' /><slot name='title-trail' /><slot name='actions' /><div data-test='stub-header-tabs'><slot name='header-tabs' /></div><slot /></div>",
|
||||
"<div><div data-test='stub-subtitle'><slot name='subtitle' /></div><div data-test='stub-title-trail'><slot name='title-trail' /></div><slot name='actions' /><div data-test='stub-header-tabs'><slot name='header-tabs' /></div><slot /></div>",
|
||||
},
|
||||
OTooltip: {
|
||||
name: "OTooltip",
|
||||
@@ -632,6 +632,39 @@ describe("OnCallResponseDetail", () => {
|
||||
expect(acked.findComponent({ name: "OnCallPersonName" }).props("email")).toBe("ana@acme.io");
|
||||
});
|
||||
|
||||
/// A long ladder line beside the title ran under the header actions, so it reads on the subtitle line.
|
||||
it("says how far the page has climbed on the subtitle line, not beside the title", async () => {
|
||||
const wrapper = await renderWith();
|
||||
const phrase = wrapper.find('[data-test="stub-subtitle"] [data-test="oncall-response-phrase"]');
|
||||
expect(phrase.exists()).toBe(true);
|
||||
expect(phrase.classes()).toContain("min-w-0");
|
||||
expect(
|
||||
wrapper.find('[data-test="stub-title-trail"] [data-test="oncall-response-phrase"]').exists(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
/// The title row holds only state and priority; an exhausted ladder is said in words on the subtitle line.
|
||||
it("says nobody answered on the subtitle line, not as a chip beside the title", async () => {
|
||||
service.escalationProgress.mockResolvedValue({
|
||||
data: {
|
||||
fired: [{ after_micros: 0, at: 1, targets: ["ana@acme.io"], reached: ["ana@acme.io"] }],
|
||||
next_targets: [],
|
||||
next_at: null,
|
||||
exhausted: true,
|
||||
},
|
||||
} as any);
|
||||
const wrapper = await renderWith();
|
||||
const words = wrapper.find(
|
||||
'[data-test="stub-subtitle"] [data-test="oncall-response-phrase-exhausted"]',
|
||||
);
|
||||
expect(words.text()).toContain("Nobody answered");
|
||||
const trail = wrapper.find('[data-test="stub-title-trail"]');
|
||||
expect(trail.findAllComponents({ name: "OTag" })).toHaveLength(2);
|
||||
expect(trail.find('[data-test="oncall-response-title-trail"]').classes()).toContain(
|
||||
"overflow-hidden",
|
||||
);
|
||||
});
|
||||
|
||||
describe("an Unacknowledge the server cannot apply", () => {
|
||||
const held = () => ({
|
||||
state: "acknowledged",
|
||||
@@ -1072,8 +1105,7 @@ describe("OnCallResponseDetail", () => {
|
||||
});
|
||||
|
||||
const tag = wrapper.find('[data-test="oncall-response-liaison-tag"]');
|
||||
const tooltip = tag.element.nextElementSibling;
|
||||
expect(tooltip?.textContent).toContain("one chase");
|
||||
expect(tag.text()).toContain("one chase");
|
||||
});
|
||||
|
||||
it("leaves an owner record with no liaison language at all", async () => {
|
||||
@@ -1604,14 +1636,15 @@ describe("OnCallResponseDetail — what the payload already knew", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/// R13, L2: the phrase keeps a readable floor and truncates past it, and the header keeps the title's floor.
|
||||
/// R13, L2: on the subtitle line the phrase gives way after team and time; in the body it keeps a readable floor; the title keeps its floor.
|
||||
it("keeps the escalation phrase readable and the title floored", async () => {
|
||||
for (const lgUp of [true, false]) {
|
||||
mockViewport.lgUp = lgUp;
|
||||
const wrapper = await renderWith();
|
||||
const classes = wrapper.find('[data-test="oncall-response-phrase"]').classes();
|
||||
expect(classes, `lg ${lgUp}`).toEqual(expect.arrayContaining(["min-w-32", "truncate"]));
|
||||
expect(classes, `lg ${lgUp}`).not.toContain("min-w-0");
|
||||
expect(classes, `lg ${lgUp}`).toEqual(
|
||||
expect.arrayContaining(lgUp ? ["min-w-0", "shrink-[100]"] : ["min-w-32", "truncate"]),
|
||||
);
|
||||
expect(wrapper.findComponent({ name: "OPageLayout" }).attributes()).toHaveProperty(
|
||||
"title-min-width",
|
||||
);
|
||||
@@ -1756,10 +1789,10 @@ describe("OnCallResponseDetail — responding", () => {
|
||||
expect(
|
||||
wrapper
|
||||
.find(
|
||||
'[data-test="oncall-response-phrase"] [data-test="oncall-escalation-cell-exhausted"]',
|
||||
'[data-test="oncall-response-phrase"] [data-test="oncall-response-phrase-exhausted"]',
|
||||
)
|
||||
.exists(),
|
||||
).toBe(true);
|
||||
.text(),
|
||||
).toContain("Nobody answered");
|
||||
const banner = wrapper.find('[data-test="oncall-response-exhausted-banner"]');
|
||||
expect(banner.text()).toMatch(
|
||||
/^Nobody answered\. Every step ran by [A-Z][a-z]{2} \d{1,2}, \d{4}, \d{2}:\d{2}\. Acknowledge to take it, or hand it off\.$/,
|
||||
|
||||
@@ -76,27 +76,25 @@
|
||||
</template>
|
||||
</i18n-t>
|
||||
</OTruncatedText>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- The two facts a responder needs before anything else ride the title,
|
||||
rather than sitting in a metadata grid below the fold. -->
|
||||
<template #title-trail>
|
||||
<!-- Below lg the title row has room for the alert's name or its tags, not both; there the tags lead the body instead. -->
|
||||
<template v-if="response && lgUp">
|
||||
<!-- The state facts never shrink; the long paging phrase gives way first. -->
|
||||
<span class="inline-flex shrink-0" data-test="oncall-response-state-tag">
|
||||
<OTag type="oncallResponseState" :value="response.state" size="sm" />
|
||||
<OTooltip v-if="closedAfter" :content="closedAfter" />
|
||||
</span>
|
||||
<OTag class="shrink-0" type="alertPriority" :value="`p${response.priority}`" size="sm" />
|
||||
<!-- A closed page's state tag already says it, and an acknowledged one names its holder in the subtitle. -->
|
||||
<!-- A closed page's state tag already says it, and an acknowledged one names its holder just before. -->
|
||||
<span
|
||||
v-if="isOpenState && response.state !== 'acknowledged'"
|
||||
class="flex min-w-32 shrink-[1000] truncate"
|
||||
v-if="lgUp && isOpenState && response.state !== 'acknowledged'"
|
||||
class="flex min-w-0 shrink-[100] items-center gap-1"
|
||||
data-test="oncall-response-phrase"
|
||||
>
|
||||
<span v-if="headerPhraseKind !== 'unknown'" class="shrink-0">{{ raw("·") }}</span>
|
||||
<!-- Words, not the list's chip: the title row keeps only state and priority. -->
|
||||
<OTruncatedText
|
||||
v-if="headerPhraseKind === 'nobodyAnswered'"
|
||||
class="text-status-error-text min-w-0"
|
||||
:tooltip="t('oncall.ladderExhausted')"
|
||||
data-test="oncall-response-phrase-exhausted"
|
||||
>
|
||||
{{ t("oncall.state_exhausted") }}
|
||||
</OTruncatedText>
|
||||
<OnCallEscalationCell
|
||||
v-else
|
||||
class="min-w-0"
|
||||
:response-id="response.id"
|
||||
:record="response"
|
||||
:progress="escalation"
|
||||
@@ -106,18 +104,34 @@
|
||||
:team-deleted="teamDeleted"
|
||||
/>
|
||||
</span>
|
||||
<!-- Why this team is looking at somebody else's outage, said where the
|
||||
state is said rather than halfway down the page. -->
|
||||
<OTag
|
||||
v-if="isImpacted"
|
||||
class="shrink-0"
|
||||
variant="info-outline"
|
||||
size="sm"
|
||||
<!-- Why this team is looking at somebody else's outage, said with the page's other facts. -->
|
||||
<span
|
||||
v-if="lgUp && isImpacted"
|
||||
class="text-status-info-text inline-flex shrink-[100] items-center gap-1 truncate"
|
||||
data-test="oncall-response-liaison-tag"
|
||||
>
|
||||
{{ t("oncall.liaisonTag") }}
|
||||
</OTag>
|
||||
<OTooltip v-if="isImpacted" side="bottom" :content="t('oncall.ladderLiaisonNote')" />
|
||||
{{ raw("·") }} {{ t("oncall.liaisonTag") }}
|
||||
<OTooltip side="bottom" :content="t('oncall.ladderLiaisonNote')" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- The two facts a responder needs before anything else ride the title,
|
||||
rather than sitting in a metadata grid below the fold. -->
|
||||
<template #title-trail>
|
||||
<!-- Below lg the title row has room for the alert's name or its tags, not both; there the tags lead the body instead. -->
|
||||
<template v-if="response && lgUp">
|
||||
<!-- The title ellipsises first; clipping here means the chips can never paint under the actions. -->
|
||||
<span
|
||||
class="flex min-w-0 shrink-[0.01] items-center gap-3.25 overflow-hidden"
|
||||
data-test="oncall-response-title-trail"
|
||||
>
|
||||
<span class="inline-flex shrink-0" data-test="oncall-response-state-tag">
|
||||
<OTag type="oncallResponseState" :value="response.state" size="sm" />
|
||||
<OTooltip v-if="closedAfter" :content="closedAfter" />
|
||||
</span>
|
||||
<OTag class="shrink-0" type="alertPriority" :value="`p${response.priority}`" size="sm" />
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -215,7 +229,7 @@
|
||||
|
||||
<!-- A menu of durations, not a panel that pushes the page down. -->
|
||||
<OButton
|
||||
v-if="canUnsnooze && lgUp"
|
||||
v-if="canUnsnooze && inlineVerbs"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:loading="snoozing"
|
||||
@@ -225,7 +239,7 @@
|
||||
<OTooltip :content="t('oncall.unsnoozeHint')" />
|
||||
<OIcon name="notifications-active" size="sm" />{{ t("oncall.unsnooze") }}
|
||||
</OButton>
|
||||
<ODropdown v-else-if="canSnooze && lgUp">
|
||||
<ODropdown v-else-if="canSnooze && inlineVerbs">
|
||||
<template #trigger>
|
||||
<OButton
|
||||
variant="outline"
|
||||
@@ -249,7 +263,7 @@
|
||||
</ODropdown>
|
||||
|
||||
<!-- Not on an unrouted page: the server refuses it there, and Hand off is the next step. -->
|
||||
<span v-if="lgUp && canEscalate" class="inline-flex">
|
||||
<span v-if="inlineVerbs && canEscalate" class="inline-flex">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -264,7 +278,7 @@
|
||||
</span>
|
||||
|
||||
<OButton
|
||||
v-if="lgUp"
|
||||
v-if="inlineVerbs"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-test="oncall-response-handoff-btn"
|
||||
@@ -281,7 +295,7 @@
|
||||
something larger routinely arrives after it was closed. Hidden once
|
||||
an incident exists, because the rail links to it from then on. -->
|
||||
<OButton
|
||||
v-if="response && !response.incident_id && lgUp"
|
||||
v-if="response && !response.incident_id && inlineVerbs"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-test="oncall-response-promote-btn"
|
||||
@@ -317,8 +331,8 @@
|
||||
</OButton>
|
||||
</template>
|
||||
|
||||
<!-- Below lg the row holds claiming and closing; the verbs used less often share one menu so the header stays two rows. -->
|
||||
<ODropdown v-if="response && !lgUp && hasMoreActions" side="bottom" align="end">
|
||||
<!-- Until the row has room beside a readable title it holds claiming and closing; the verbs used less often share one menu. -->
|
||||
<ODropdown v-if="response && !inlineVerbs && hasMoreActions" side="bottom" align="end">
|
||||
<template #trigger>
|
||||
<OButton
|
||||
variant="outline"
|
||||
@@ -379,10 +393,7 @@
|
||||
</template>
|
||||
|
||||
<template v-if="response">
|
||||
<!-- OPageLayout keeps the body a fixed column (`scroll` off) so a table
|
||||
can own its own scroller. This page is a document, so the body is
|
||||
the scroller — without it the activity thread was clipped at the
|
||||
fold with no way to reach the rest. -->
|
||||
<!-- This page is a document, so the body is the scroller; the note box sticks to its foot. -->
|
||||
<OContent y class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
v-if="!lgUp"
|
||||
@@ -526,9 +537,11 @@
|
||||
analysis panel that sits empty forever. -->
|
||||
<OnCallVerdictCard :events="events" />
|
||||
|
||||
<OCard variant="glass" data-test="oncall-response-tabs">
|
||||
<!-- Not clipped, or the note box could not stick to the scrolling body's foot. -->
|
||||
<OCard variant="glass" class="overflow-visible" data-test="oncall-response-tabs">
|
||||
<OCardSection role="body" dense>
|
||||
<OTabPanels v-model="activeDetailTab">
|
||||
<!-- Unclipped, or it becomes the note box's sticky container instead of the page. -->
|
||||
<OTabPanels v-model="activeDetailTab" class="overflow-visible!">
|
||||
<OTabPanel name="activity">
|
||||
<div data-test="oncall-response-activity">
|
||||
<div v-if="systemActivityCount" class="flex justify-end pt-2">
|
||||
@@ -970,6 +983,7 @@ import {
|
||||
isUnresolved,
|
||||
formatOnCallInstant,
|
||||
isDeletedTeam,
|
||||
pagePhrase,
|
||||
pageState,
|
||||
promoteSeverityFloor,
|
||||
promoteSeverityOptions,
|
||||
@@ -982,10 +996,21 @@ import { formatTimestampInTimezone } from "@/utils/date";
|
||||
const HOLD_RECHECK_MS = 10_000;
|
||||
/// Re-reads of a lapsed rung before giving up: a minute at HOLD_RECHECK_MS.
|
||||
const LAPSE_RECHECKS = 6;
|
||||
// Every verb inline takes about 56rem, which below this leaves the title a few letters wide.
|
||||
const INLINE_VERBS_QUERY = "(min-width: 96rem)";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const { nameOf, tooltipOf } = useOrgUserNames();
|
||||
const { lgUp } = useBreakpoint();
|
||||
const inlineVerbsMql =
|
||||
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||
? window.matchMedia(INLINE_VERBS_QUERY)
|
||||
: null;
|
||||
const wideHeader = ref(inlineVerbsMql?.matches ?? true);
|
||||
const onWideHeader = (e: MediaQueryListEvent) => (wideHeader.value = e.matches);
|
||||
inlineVerbsMql?.addEventListener?.("change", onWideHeader);
|
||||
onBeforeUnmount(() => inlineVerbsMql?.removeEventListener?.("change", onWideHeader));
|
||||
const inlineVerbs = computed(() => lgUp.value && wideHeader.value);
|
||||
const nowMicros = useOnCallClock();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
@@ -1258,6 +1283,20 @@ const headerReachedStep = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
/// The subtitle's separator waits for a phrase, so an unanswered ladder leaves no dangling dot.
|
||||
const headerPhraseKind = computed(() =>
|
||||
response.value
|
||||
? pagePhrase({
|
||||
record: response.value,
|
||||
progress: escalation.value,
|
||||
progressLoaded: escalationLoaded.value,
|
||||
totalRungs: totalRungs.value,
|
||||
reachedStep: headerReachedStep.value,
|
||||
nowMicros: nowMicros.value,
|
||||
}).kind
|
||||
: "unknown",
|
||||
);
|
||||
|
||||
// The same progress decides the header tag; the time is the last step's, which the record's flag lags.
|
||||
const exhaustedAtClock = computed(() => {
|
||||
const r = response.value;
|
||||
|
||||
Reference in New Issue
Block a user